Add replies and emoji reactions to chat messages
Every message has a row under it to reply or react. Replying quotes the message above the composer, and the sent message stores reply_to_message_id (a plain id, not a foreign key, so deleting the quoted message only drops the quote). send-message accepts a quote from any session of the same conversation, so a rollover or a thread that moved on still sends; the conversation drops a picked quote when its session changes. The bot reads the quote as "[Replying to ...]" before the user's turn, file-only replies included, and relayed messages carry it too. Reactions are a fixed set (BotMessageReactions) in bot_message_reactions, one per message, emoji and user or bot. Users toggle their own through api/admin/bots/react-message, and every bot gets react_to_message for the session it answers (the user's latest message by default). Both push the fresh list as server:bot-reaction, with mine worked out for the session's user. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -67,7 +67,7 @@ export default async function grabConversationProps({
|
||||
* shows its pick.
|
||||
*/
|
||||
const [bot_messages, bot_harness_overview, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
|
||||
grabBotMessages({ session_id: session.id! }),
|
||||
grabBotMessages({ session_id: session.id!, user_id }),
|
||||
(bot.kind == "coordinator" && !scope
|
||||
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||
: grabBotHarnessOverview({
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
|
||||
import grabMessagesReactions from "./grab-messages-reactions";
|
||||
|
||||
type Params = {
|
||||
message_id: number;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The user whose conversation it is, so the returned list marks their
|
||||
* own reactions.
|
||||
*/
|
||||
user_id: number | string;
|
||||
emoji: BotMessageReactionEmoji;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a bot's reaction to a message (once per emoji) and returns the
|
||||
* message's fresh reactions.
|
||||
*/
|
||||
export default async function addBotMessageReaction({ message_id, bot_id, user_id, emoji }: Params) {
|
||||
const now = Date.now();
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO bot_message_reactions (created_at, updated_at, message_id, bot_id, emoji) VALUES (?, ?, ?, ?, ?)`,
|
||||
values: [now, now, message_id, bot_id, emoji],
|
||||
});
|
||||
|
||||
return (await grabMessagesReactions({ message_ids: [message_id], user_id })).get(
|
||||
message_id,
|
||||
) || [];
|
||||
}
|
||||
@@ -2,19 +2,28 @@ import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabMessagesMedia from "./grab-messages-media";
|
||||
import grabMessagesReactions from "./grab-messages-reactions";
|
||||
import grabMessagesReplies from "./grab-messages-replies";
|
||||
import mediaToAttachment from "./media-to-attachment";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
limit?: number;
|
||||
/**
|
||||
* Whose conversation it is, so a reaction can show as the user's own.
|
||||
* Left out for the bot's own history reads, which don't need it.
|
||||
*/
|
||||
user_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The latest messages of a session, oldest first, with their attachments.
|
||||
* The latest messages of a session, oldest first, with their attachments,
|
||||
* what each replies to, and (when `user_id` is given) its reactions.
|
||||
*/
|
||||
export default async function grabBotMessages({
|
||||
session_id,
|
||||
limit = 100,
|
||||
user_id,
|
||||
}: Params): Promise<BotMessageWithAttachments[]> {
|
||||
let sql = ``;
|
||||
|
||||
@@ -31,15 +40,28 @@ export default async function grabBotMessages({
|
||||
});
|
||||
|
||||
const messages = res.payload || [];
|
||||
const media = await grabMessagesMedia({
|
||||
message_ids: messages.map((message) => Number(message.id)),
|
||||
});
|
||||
const message_ids = messages.map((message) => Number(message.id));
|
||||
|
||||
const [media, replies, reactions] = await Promise.all([
|
||||
grabMessagesMedia({ message_ids }),
|
||||
grabMessagesReplies({ message_ids }),
|
||||
user_id
|
||||
? grabMessagesReactions({ message_ids, user_id })
|
||||
: Promise.resolve(new Map()),
|
||||
]);
|
||||
|
||||
return messages.map((message) => {
|
||||
const files = media.get(Number(message.id));
|
||||
const id = Number(message.id);
|
||||
const files = media.get(id);
|
||||
const reply_to = replies.get(id);
|
||||
|
||||
return files
|
||||
? { ...message, attachments: files.map((file) => mediaToAttachment({ media: file })) }
|
||||
: message;
|
||||
return {
|
||||
...message,
|
||||
...(files
|
||||
? { attachments: files.map((file) => mediaToAttachment({ media: file })) }
|
||||
: {}),
|
||||
...(reply_to ? { reply_to } : {}),
|
||||
...(user_id ? { reactions: reactions.get(id) || [] } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The newest message the user wrote in a session, which a bot's reaction
|
||||
* defaults to.
|
||||
*/
|
||||
export default async function grabLatestUserMessage({
|
||||
session_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES | null> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||
sql: `SELECT * FROM bot_messages WHERE session_id = ? AND author = 'user' ORDER BY id DESC LIMIT 1`,
|
||||
values: [session_id],
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import {
|
||||
BotMessageReactions,
|
||||
type BotMessageReactionEmoji,
|
||||
} from "@/src/dict/bot-dict";
|
||||
import type { BotMessageReaction } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
message_ids: number[];
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
type ReactionRow = {
|
||||
message_id: number;
|
||||
emoji: BotMessageReactionEmoji;
|
||||
user_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A message's reactions, grouped by emoji, in the picker's order, with
|
||||
* whether the given user reacted and how many did.
|
||||
*/
|
||||
export default async function grabMessagesReactions({ message_ids, user_id }: Params) {
|
||||
const by_message = new Map<number, BotMessageReaction[]>();
|
||||
const ids = [...new Set(message_ids.filter(Boolean))];
|
||||
|
||||
if (!ids.length) return by_message;
|
||||
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<ReactionRow>({
|
||||
sql: `SELECT message_id, emoji, user_id FROM bot_message_reactions
|
||||
WHERE message_id IN (${ids.map(() => "?").join(", ")})
|
||||
ORDER BY id ASC`,
|
||||
values: ids,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
const order = new Map<string, number>(
|
||||
BotMessageReactions.map((reaction, index) => [reaction.value, index]),
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const list = by_message.get(row.message_id) || [];
|
||||
const found = list.find((reaction) => reaction.emoji == row.emoji);
|
||||
|
||||
if (found) {
|
||||
found.count += 1;
|
||||
found.mine = found.mine || String(row.user_id) == String(user_id);
|
||||
} else {
|
||||
list.push({
|
||||
emoji: row.emoji,
|
||||
count: 1,
|
||||
mine: String(row.user_id) == String(user_id),
|
||||
});
|
||||
}
|
||||
|
||||
by_message.set(row.message_id, list);
|
||||
}
|
||||
|
||||
for (const list of by_message.values()) {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(order.get(a.emoji) ?? BotMessageReactions.length) -
|
||||
(order.get(b.emoji) ?? BotMessageReactions.length),
|
||||
);
|
||||
}
|
||||
|
||||
return by_message;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BotMessageReply } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
message_ids: number[];
|
||||
};
|
||||
|
||||
type ReplyRow = BotMessageReply & { message_id: number };
|
||||
|
||||
/**
|
||||
* The messages quoted by these messages, by the replying message's id, so
|
||||
* a reply can show what it answers without loading the whole history.
|
||||
*/
|
||||
export default async function grabMessagesReplies({ message_ids }: Params) {
|
||||
const by_message = new Map<number, BotMessageReply>();
|
||||
const ids = [...new Set(message_ids.filter(Boolean))];
|
||||
|
||||
if (!ids.length) return by_message;
|
||||
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<ReplyRow>({
|
||||
sql: `SELECT r.id, r.author, r.text, m.id AS message_id FROM bot_messages m
|
||||
JOIN bot_messages r ON r.id = m.reply_to_message_id
|
||||
WHERE m.id IN (${ids.map(() => "?").join(", ")})`,
|
||||
values: ids,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
for (const { message_id, ...reply } of rows) {
|
||||
by_message.set(Number(message_id), reply);
|
||||
}
|
||||
|
||||
return by_message;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
message_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A message only if it's in the given session, so a reply can't quote
|
||||
* another conversation's message.
|
||||
*/
|
||||
export default async function grabSessionMessage({
|
||||
session_id,
|
||||
message_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES | null> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||
sql: `SELECT * FROM bot_messages WHERE id = ? AND session_id = ? LIMIT 1`,
|
||||
values: [message_id, session_id],
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
message_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A message in one of the user's own sessions, whoever wrote it. Throws
|
||||
* when it isn't theirs.
|
||||
*/
|
||||
export default async function grabUserBotMessage({
|
||||
user_id,
|
||||
message_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||
const message = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||
sql: `SELECT m.* FROM bot_messages m
|
||||
JOIN bot_sessions s ON s.id = m.session_id
|
||||
WHERE m.id = ? AND s.user_id = ? LIMIT 1`,
|
||||
values: [message_id, user_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!message?.id) {
|
||||
throw new AppError(`Message not found`);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
@@ -15,6 +15,11 @@ type Params = {
|
||||
session_id: number;
|
||||
workspace_id?: number | null;
|
||||
text: string;
|
||||
/**
|
||||
* The message this one answers, quoted above it. Already checked to
|
||||
* be in the same conversation.
|
||||
*/
|
||||
reply_to?: BUN_SQLITE_COBBLE_BOT_MESSAGES | null;
|
||||
/**
|
||||
* Files to attach, already checked to be the user's own
|
||||
* (`grabAttachableMedia`).
|
||||
@@ -33,6 +38,7 @@ export default async function insertUserMessage({
|
||||
session_id,
|
||||
workspace_id,
|
||||
text,
|
||||
reply_to,
|
||||
media = [],
|
||||
}: Params): Promise<BotMessageWithAttachments> {
|
||||
const now = Date.now();
|
||||
@@ -50,6 +56,7 @@ export default async function insertUserMessage({
|
||||
author: "user",
|
||||
text,
|
||||
needs_you: 0,
|
||||
reply_to_message_id: reply_to?.id || undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -80,8 +87,16 @@ export default async function insertUserMessage({
|
||||
author: "user",
|
||||
text,
|
||||
needs_you: 0,
|
||||
reply_to_message_id: reply_to?.id || undefined,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
attachments: media.map((file) => mediaToAttachment({ media: file })),
|
||||
reply_to: reply_to
|
||||
? {
|
||||
id: Number(reply_to.id),
|
||||
author: reply_to.author == "bot" ? "bot" : "user",
|
||||
text: reply_to.text || "",
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabMessagesReactions from "./grab-messages-reactions";
|
||||
|
||||
type Params = {
|
||||
message_id: number;
|
||||
user_id: number | string;
|
||||
emoji: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a reaction, or takes it back when the user already gave it, and
|
||||
* returns the message's fresh reactions.
|
||||
*/
|
||||
export default async function toggleMessageReaction({ message_id, user_id, emoji }: Params) {
|
||||
const existing = (
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
sql: `SELECT id FROM bot_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ? LIMIT 1`,
|
||||
values: [message_id, user_id, emoji],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (existing?.id) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM bot_message_reactions WHERE id = ?`,
|
||||
values: [existing.id],
|
||||
});
|
||||
} else {
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO bot_message_reactions (created_at, updated_at, message_id, user_id, emoji) VALUES (?, ?, ?, ?, ?)`,
|
||||
values: [Date.now(), Date.now(), message_id, user_id, emoji],
|
||||
});
|
||||
}
|
||||
|
||||
return (await grabMessagesReactions({ message_ids: [message_id], user_id })).get(message_id) || [];
|
||||
}
|
||||
Reference in New Issue
Block a user