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:
2026-10-19 21:56:31 +01:00
co-authored by Claude Opus 5.5
parent bc499a7466
commit d1b5b6d68a
44 changed files with 1289 additions and 58 deletions
@@ -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) || [];
}