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
@@ -36,6 +36,73 @@ describe("botMessagesToHarness", () => {
});
});
describe("botMessagesToHarness with replies", () => {
test("quotes what a user message answers", () => {
expect(
botMessagesToHarness({
messages: [
{ author: "user", text: "hi" },
{ author: "bot", text: "The report is ready." },
{
author: "user",
text: "Great, send it over.",
reply_to: { id: 4, author: "bot", text: "The report is ready." },
},
],
}),
).toEqual([
{ role: "user", content: "hi" },
{ role: "assistant", content: "The report is ready." },
{
role: "user",
content: `[Replying to your earlier message: "The report is ready."]\n\nGreat, send it over.`,
},
]);
});
test("says a quoted file-only message was a file", () => {
expect(
botMessagesToHarness({
messages: [
{
author: "user",
text: "What was that?",
reply_to: { id: 5, author: "user", text: "" },
},
],
}),
).toEqual([
{
role: "user",
content: `[Replying to the user's own earlier message: "(a file)"]\n\nWhat was that?`,
},
]);
});
});
describe("botMessagesToHarness with a file-only reply", () => {
test("keeps the quote when the reply has no text", () => {
expect(
botMessagesToHarness({
messages: [
{
id: 7,
author: "user",
text: "",
reply_to: { id: 4, author: "bot", text: "Send me the logo." },
attachments: [{ id: 9, name: "logo.png", media_type: "image", url: "/media/private/9" }],
},
],
}),
).toEqual([
{
role: "user",
content: `[Replying to your earlier message: "Send me the logo."]\n\n[Attached earlier: "logo.png"]`,
},
]);
});
});
describe("botMessagesToHarness with files", () => {
const attachments = [{ id: 9, name: "shot.png", media_type: "image" as const, url: "/media/private/9" }];