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
+60
View File
@@ -0,0 +1,60 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserBotMessage from "@/src/functions/backend/db/bots/grab-user-bot-message";
import toggleMessageReaction from "@/src/functions/backend/db/bots/toggle-message-reaction";
import pushToSessionUser from "@/src/bots-queue/push-to-session-user";
import { ReactToBotMessageRequestSchema } from "@/src/schemas/bot-schema";
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
/**
* Adds or takes back one of the fixed emoji reactions on a message in the
* session user's own conversation, and pushes the message's fresh
* reactions to their other open tabs.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { message_id, emoji } = parseWithSchema({
schema: ReactToBotMessageRequestSchema,
data: body,
});
const message = await grabUserBotMessage({ user_id: user.id, message_id });
const reactions = await toggleMessageReaction({
message_id: Number(message.id),
user_id: user.id,
emoji,
});
await pushToSessionUser({
session_id: message.session_id,
data: {
event: "server:bot-reaction",
data: { message_id: Number(message.id), reactions },
},
});
return {
success: true,
singleRes: { message_id: Number(message.id), reactions },
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+23 -1
View File
@@ -2,6 +2,7 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
import insertUserMessage from "@/src/functions/backend/db/bots/insert-user-message";
import grabUserBotMessage from "@/src/functions/backend/db/bots/grab-user-bot-message";
import grabAttachableMedia from "@/src/functions/backend/db/bots/grab-attachable-media";
import enqueueBotReply from "@/src/functions/backend/db/bots-queue/enqueue-bot-reply";
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
@@ -47,7 +48,7 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
throw new AppError(`This user can only read records.`);
}
const { bot_id, workspace_id, text, media_ids } = parseWithSchema({
const { bot_id, workspace_id, text, media_ids, reply_to_message_id } = parseWithSchema({
schema: SendBotMessageRequestSchema,
data: body,
});
@@ -63,11 +64,32 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
rollover: true,
});
/**
* The quoted message only has to be in this conversation, not in
* the session the message lands in: sending can roll over to a new
* one, or the thread may have moved on.
*/
const reply_to = reply_to_message_id
? await grabUserBotMessage({ user_id: user.id, message_id: reply_to_message_id }).catch(
() => null,
)
: null;
if (
reply_to_message_id &&
(!reply_to ||
reply_to.bot_id != bot.id ||
(reply_to.workspace_id || null) != (conversation.workspace_id || null))
) {
throw new AppError(`That message is no longer in this conversation.`);
}
const message = await insertUserMessage({
bot_id: bot.id,
session_id: Number(session.id),
workspace_id: conversation.workspace_id,
text,
reply_to,
media,
});
@@ -43,6 +43,7 @@ export const handler: BunextAPIRouteHandler<
payload: await grabBotMessages({
session_id: session.id,
limit: AppData.BotReplyCatchUpMessages,
user_id: user.id,
}),
};
} catch (error: unknown) {