From a2b5b1f0cb09cfc7666ddd99d262290076e89df0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 04:04:05 +0000 Subject: [PATCH] refactor: normalize inbound context --- extensions/matrix/src/matrix/monitor/index.ts | 7 ++++++- .../src/monitor-handler/message-handler.ts | 5 ++++- extensions/zalo/src/monitor.ts | 3 +++ extensions/zalouser/src/monitor.ts | 3 +++ src/agents/tools/session-status-tool.ts | 1 + src/auto-reply/reply/get-reply-directives.ts | 11 +++++++++-- .../reply/get-reply-inline-actions.ts | 3 +++ src/auto-reply/reply/get-reply-run.ts | 5 ++++- .../reply/inbound-sender-meta.test.ts | 4 ++-- src/auto-reply/reply/inbound-sender-meta.ts | 15 ++++----------- src/auto-reply/reply/session.ts | 17 ++++++++++++++--- src/auto-reply/status.ts | 1 + src/auto-reply/templating.ts | 12 ++++++++++++ src/commands/sessions.ts | 4 +++- src/commands/status.summary.ts | 4 +++- src/config/sessions/group.ts | 2 +- src/config/sessions/types.ts | 7 ++++++- src/config/zod-schema.session.ts | 8 +++++++- src/discord/monitor/message-handler.process.ts | 5 ++++- src/discord/monitor/native-command.ts | 6 +++++- src/gateway/session-utils.ts | 4 +++- src/gateway/session-utils.types.ts | 2 +- src/imessage/monitor/monitor-provider.ts | 3 +++ src/signal/monitor/event-handler.ts | 3 +++ src/slack/monitor/message-handler/prepare.ts | 14 ++++++++++++-- src/slack/monitor/slash.ts | 16 +++++++++++++++- src/telegram/bot-message-context.ts | 8 +++++++- src/telegram/bot-native-commands.ts | 6 ++++++ src/telegram/bot.test.ts | 3 +++ ...up-activation-silent-token-preserves.test.ts | 2 ++ src/web/auto-reply/monitor/process-message.ts | 6 +++++- 31 files changed, 155 insertions(+), 35 deletions(-) diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 254b71e6b..def5b88e1 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -356,11 +356,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; const ctxPayload = { Body: body, + BodyForAgent: body, + RawBody: bodyText, + CommandBody: bodyText, + BodyForCommands: bodyText, From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, To: `room:${roomId}`, SessionKey: route.sessionKey, AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : "room", + ChatType: isDirectMessage ? "direct" : "channel", + ConversationLabel: envelopeFrom, SenderName: senderName, SenderId: senderId, SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 9c63ce04d..eab1f6217 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -383,13 +383,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const ctxPayload = { Body: combinedBody, + BodyForAgent: combinedBody, RawBody: rawBody, CommandBody: rawBody, + BodyForCommands: rawBody, From: teamsFrom, To: teamsTo, SessionKey: route.sessionKey, AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : isChannel ? "room" : "group", + ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group", + ConversationLabel: envelopeFrom, GroupSubject: !isDirectMessage ? conversationType : undefined, SenderName: senderName, SenderId: senderId, diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 377c6ba3e..fff7934d3 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -508,13 +508,16 @@ async function processMessageWithPipeline(params: { const ctxPayload = { Body: body, + BodyForAgent: body, RawBody: rawBody, CommandBody: rawBody, + BodyForCommands: rawBody, From: isGroup ? `group:${chatId}` : `zalo:${senderId}`, To: `zalo:${chatId}`, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", + ConversationLabel: fromLabel, SenderName: senderName || undefined, SenderId: senderId, Provider: "zalo", diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 2f283d9de..ff8e65f63 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -183,13 +183,16 @@ async function processMessage( const ctxPayload = { Body: body, + BodyForAgent: body, RawBody: rawBody, CommandBody: rawBody, + BodyForCommands: rawBody, From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`, To: `zalouser:${chatId}`, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", + ConversationLabel: fromLabel, SenderName: senderName || undefined, SenderId: senderId, Provider: "zalouser", diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 9385916e2..45bfe4328 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -308,6 +308,7 @@ export function createSessionStatusTool(opts?: { const isGroup = resolved.entry.chatType === "group" || + resolved.entry.chatType === "channel" || resolved.entry.chatType === "room" || resolved.key.startsWith("group:") || resolved.key.includes(":group:") || diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 27ec6144f..67bfae946 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -120,12 +120,18 @@ export async function resolveReplyDirectives(params: { // Prefer CommandBody/RawBody (clean message without structural context) for directive parsing. // Keep `Body`/`BodyStripped` as the best-available prompt text (may include context). const commandSource = + sessionCtx.BodyForCommands ?? sessionCtx.CommandBody ?? sessionCtx.RawBody ?? sessionCtx.Transcript ?? sessionCtx.BodyStripped ?? sessionCtx.Body ?? + ctx.BodyForCommands ?? + ctx.CommandBody ?? + ctx.RawBody ?? ""; + const promptSource = sessionCtx.BodyForAgent ?? sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; + const commandText = commandSource || promptSource; const command = buildCommandContext({ ctx, cfg, @@ -162,7 +168,7 @@ export async function resolveReplyDirectives(params: { .filter((alias): alias is string => Boolean(alias)) .filter((alias) => !reservedCommands.has(alias.toLowerCase())); const allowStatusDirective = allowTextCommands && command.isAuthorizedSender; - let parsedDirectives = parseInlineDirectives(commandSource, { + let parsedDirectives = parseInlineDirectives(commandText, { modelAliases: configuredAliases, allowStatusDirective, }); @@ -253,6 +259,7 @@ export async function resolveReplyDirectives(params: { cleanedBody = stripInlineStatus(cleanedBody).cleaned; } + sessionCtx.BodyForAgent = cleanedBody; sessionCtx.Body = cleanedBody; sessionCtx.BodyStripped = cleanedBody; @@ -402,7 +409,7 @@ export async function resolveReplyDirectives(params: { return { kind: "continue", result: { - commandSource, + commandSource: commandText, command, allowTextCommands, skillCommands, diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 579438cbb..2452fdd8f 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -135,7 +135,9 @@ export async function handleInlineActions(params: { ].filter((entry): entry is string => Boolean(entry)); const rewrittenBody = promptParts.join("\n\n"); ctx.Body = rewrittenBody; + ctx.BodyForAgent = rewrittenBody; sessionCtx.Body = rewrittenBody; + sessionCtx.BodyForAgent = rewrittenBody; sessionCtx.BodyStripped = rewrittenBody; cleanedBody = rewrittenBody; } @@ -153,6 +155,7 @@ export async function handleInlineActions(params: { if (inlineCommand) { cleanedBody = inlineCommand.cleaned; sessionCtx.Body = cleanedBody; + sessionCtx.BodyForAgent = cleanedBody; sessionCtx.BodyStripped = cleanedBody; } diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index a87877ad5..870b1c184 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -295,7 +295,10 @@ export async function runPreparedReply( abortKey: command.abortKey, messageId: sessionCtx.MessageSid, }); - const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room"; + const isGroupSession = + sessionEntry?.chatType === "group" || + sessionEntry?.chatType === "channel" || + sessionEntry?.chatType === "room"; const isMainSession = !isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey); prefixedBodyBase = await prependSystemEvents({ cfg, diff --git a/src/auto-reply/reply/inbound-sender-meta.test.ts b/src/auto-reply/reply/inbound-sender-meta.test.ts index fd083c134..6740621ed 100644 --- a/src/auto-reply/reply/inbound-sender-meta.test.ts +++ b/src/auto-reply/reply/inbound-sender-meta.test.ts @@ -28,10 +28,10 @@ describe("formatInboundBodyWithSenderMeta", () => { ); }); - it("preserves escaped newline style when body uses literal \\\\n", () => { + it("appends with a real newline even if the body contains literal \\\\n", () => { const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" }; expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe( - "[X] one\\n[X] two\\n[from: Bob (+222)]", + "[X] one\\n[X] two\n[from: Bob (+222)]", ); }); diff --git a/src/auto-reply/reply/inbound-sender-meta.ts b/src/auto-reply/reply/inbound-sender-meta.ts index 5d4ff3f20..5dc0a7808 100644 --- a/src/auto-reply/reply/inbound-sender-meta.ts +++ b/src/auto-reply/reply/inbound-sender-meta.ts @@ -1,28 +1,21 @@ import type { MsgContext } from "../templating.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; export function formatInboundBodyWithSenderMeta(params: { body: string; ctx: MsgContext }): string { const body = params.body; if (!body.trim()) return body; - const chatType = params.ctx.ChatType?.trim().toLowerCase(); + const chatType = normalizeChatType(params.ctx.ChatType); if (!chatType || chatType === "direct") return body; if (hasSenderMetaLine(body)) return body; const senderLabel = formatSenderLabel(params.ctx); if (!senderLabel) return body; - const lineBreak = resolveBodyLineBreak(body); - return `${body}${lineBreak}[from: ${senderLabel}]`; -} - -function resolveBodyLineBreak(body: string): string { - const hasEscaped = body.includes("\\n"); - const hasNewline = body.includes("\n"); - if (hasEscaped && !hasNewline) return "\\n"; - return "\n"; + return `${body}\n[from: ${senderLabel}]`; } function hasSenderMetaLine(body: string): boolean { - return /(^|\n|\\n)\[from:/i.test(body); + return /(^|\n)\[from:/i.test(body); } function formatSenderLabel(ctx: MsgContext): string | null { diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index d66369177..40ea12a4d 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -25,8 +25,10 @@ import { import { normalizeMainKey } from "../../routing/session-key.js"; import { resolveCommandAuthorization } from "../command-auth.js"; import type { MsgContext, TemplateContext } from "../templating.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js"; +import { normalizeInboundTextNewlines } from "./inbound-text.js"; export type SessionInitResult = { sessionCtx: TemplateContext; @@ -126,10 +128,11 @@ export async function initSessionState(params: { let persistedProviderOverride: string | undefined; const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined; - const isGroup = ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution); + const normalizedChatType = normalizeChatType(ctx.ChatType); + const isGroup = normalizedChatType != null && normalizedChatType !== "direct" ? true : Boolean(groupResolution); // Prefer CommandBody/RawBody (clean message) for command detection; fall back // to Body which may contain structural context (history, sender labels). - const commandSource = ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? ""; + const commandSource = ctx.BodyForCommands ?? ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? ""; const triggerBodyNormalized = stripStructuralPrefixes(commandSource).trim().toLowerCase(); // Use CommandBody/RawBody for reset trigger matching (clean message without structural context). @@ -308,7 +311,15 @@ export async function initSessionState(params: { // RawBody is reserved for command/directive parsing and may omit context. BodyStripped: formatInboundBodyWithSenderMeta({ ctx, - body: bodyStripped ?? ctx.Body ?? ctx.CommandBody ?? ctx.RawBody ?? "", + body: normalizeInboundTextNewlines( + bodyStripped ?? + ctx.BodyForAgent ?? + ctx.Body ?? + ctx.CommandBody ?? + ctx.RawBody ?? + ctx.BodyForCommands ?? + "", + ), }), SessionId: sessionId, IsNewSession: isNewSession ? "true" : "false", diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index aef421f32..d54eff98f 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -253,6 +253,7 @@ export function buildStatusMessage(args: StatusArgs): string { const isGroupSession = entry?.chatType === "group" || + entry?.chatType === "channel" || entry?.chatType === "room" || Boolean(args.sessionKey?.includes(":group:")) || Boolean(args.sessionKey?.includes(":channel:")) || diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 7bf105b86..e3e7f65b6 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -8,6 +8,11 @@ export type OriginatingChannelType = ChannelId | InternalMessageChannel; export type MsgContext = { Body?: string; + /** + * Agent prompt body (may include envelope/history/context). Prefer this for prompt shaping. + * Should use real newlines (`\n`), not escaped `\\n`. + */ + BodyForAgent?: string; /** * Raw message body without structural context (history, sender labels). * Legacy alias for CommandBody. Falls back to Body if not set. @@ -17,6 +22,11 @@ export type MsgContext = { * Prefer for command detection; RawBody is treated as legacy alias. */ CommandBody?: string; + /** + * Command parsing body. Prefer this over CommandBody/RawBody when set. + * Should be the "clean" text (no history/sender context). + */ + BodyForCommands?: string; CommandArgs?: CommandArgs; From?: string; To?: string; @@ -46,6 +56,8 @@ export type MsgContext = { Prompt?: string; MaxChars?: number; ChatType?: string; + /** Human label for envelope headers (conversation label, not sender). */ + ConversationLabel?: string; GroupSubject?: string; GroupRoom?: string; GroupSpace?: string; diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 2e2dd881e..e18451924 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -110,7 +110,9 @@ const formatAge = (ms: number | null | undefined) => { function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] { if (key === "global") return "global"; if (key === "unknown") return "unknown"; - if (entry?.chatType === "group" || entry?.chatType === "room") return "group"; + if (entry?.chatType === "group" || entry?.chatType === "channel" || entry?.chatType === "room") { + return "group"; + } if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) { return "group"; } diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 3f6d5ea94..f310bacc2 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -19,7 +19,9 @@ import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.typ const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] => { if (key === "global") return "global"; if (key === "unknown") return "unknown"; - if (entry?.chatType === "group" || entry?.chatType === "room") return "group"; + if (entry?.chatType === "group" || entry?.chatType === "channel" || entry?.chatType === "room") { + return "group"; + } if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) { return "group"; } diff --git a/src/config/sessions/group.ts b/src/config/sessions/group.ts index 9fd5fb327..879329a8b 100644 --- a/src/config/sessions/group.ts +++ b/src/config/sessions/group.ts @@ -136,6 +136,6 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu legacyKey, channel: resolvedProvider, id: id || raw || from, - chatType: resolvedKind === "channel" ? "room" : "group", + chatType: resolvedKind === "channel" ? "channel" : "group", }; } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index ba09f4ca5..a95e33ab2 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -7,7 +7,12 @@ export type SessionScope = "per-sender" | "global"; export type SessionChannelId = ChannelId | "webchat"; -export type SessionChatType = "direct" | "group" | "room"; +export type SessionChatType = + | "direct" + | "group" + | "channel" + // Legacy alias for "channel". + | "room"; export type SessionEntry = { /** diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index df6239e05..1f3e6ff9f 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -39,7 +39,13 @@ export const SessionSchema = z .object({ channel: z.string().optional(), chatType: z - .union([z.literal("direct"), z.literal("group"), z.literal("room")]) + .union([ + z.literal("direct"), + z.literal("group"), + z.literal("channel"), + // Legacy alias for "channel". + z.literal("room"), + ]) .optional(), keyPrefix: z.string().optional(), }) diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index f136a2e64..a528c3817 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -221,13 +221,16 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const ctxPayload = { Body: combinedBody, + BodyForAgent: combinedBody, RawBody: baseText, CommandBody: baseText, + BodyForCommands: baseText, From: effectiveFrom, To: effectiveTo, SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey, AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : "group", + ChatType: isDirectMessage ? "direct" : "channel", + ConversationLabel: fromLabel, SenderName: data.member?.nickname ?? author.globalName ?? author.username, SenderId: author.id, SenderUsername: author.username, diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 60e1931c7..33bd9109e 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -569,16 +569,20 @@ async function dispatchDiscordCommandInteraction(params: { id: isDirectMessage ? user.id : channelId, }, }); + const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId; const ctxPayload = { Body: prompt, + BodyForAgent: prompt, CommandBody: prompt, + BodyForCommands: prompt, CommandArgs: commandArgs, From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`, To: `slash:${user.id}`, SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`, CommandTargetSessionKey: route.sessionKey, AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : "group", + ChatType: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", + ConversationLabel: conversationLabel, GroupSubject: isGuild ? interaction.guild?.name : undefined, GroupSystemPrompt: isGuild ? (() => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 2a53c0876..2e8847067 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -59,7 +59,9 @@ export function loadSessionEntry(sessionKey: string) { export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] { if (key === "global") return "global"; if (key === "unknown") return "unknown"; - if (entry?.chatType === "group" || entry?.chatType === "room") return "group"; + if (entry?.chatType === "group" || entry?.chatType === "channel" || entry?.chatType === "room") { + return "group"; + } if (key.startsWith("group:") || key.includes(":group:") || key.includes(":channel:")) { return "group"; } diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 1cb4c96a4..50a60b8c9 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -15,7 +15,7 @@ export type GatewaySessionRow = { subject?: string; room?: string; space?: string; - chatType?: "direct" | "group" | "room"; + chatType?: "direct" | "group" | "channel" | "room"; updatedAt: number | null; sessionId?: string; systemSent?: boolean; diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 67c376b48..924a46dc2 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -389,13 +389,16 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const imessageTo = (isGroup ? chatTarget : undefined) || `imessage:${sender}`; const ctxPayload = { Body: combinedBody, + BodyForAgent: combinedBody, RawBody: bodyText, CommandBody: bodyText, + BodyForCommands: bodyText, From: isGroup ? `group:${chatId}` : `imessage:${sender}`, To: imessageTo, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", + ConversationLabel: fromLabel, GroupSubject: isGroup ? (message.chat_name ?? undefined) : undefined, GroupMembers: isGroup ? (message.participants ?? []).filter(Boolean).join(", ") : undefined, SenderName: senderNormalized, diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 02b618679..4e42d0ca6 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -104,8 +104,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`; const ctxPayload = { Body: combinedBody, + BodyForAgent: combinedBody, RawBody: entry.bodyText, CommandBody: entry.bodyText, + BodyForCommands: entry.bodyText, From: entry.isGroup ? `group:${entry.groupId ?? "unknown"}` : `signal:${entry.senderRecipient}`, @@ -113,6 +115,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: entry.isGroup ? "group" : "direct", + ConversationLabel: fromLabel, GroupSubject: entry.isGroup ? (entry.groupName ?? undefined) : undefined, SenderName: entry.senderName, SenderId: entry.senderDisplay, diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 40e5854dd..7855bced8 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -14,6 +14,7 @@ import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; import { resolveMentionGating } from "../../../channels/mention-gating.js"; +import { resolveConversationLabel } from "../../../channels/conversation-label.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; @@ -330,7 +331,13 @@ export async function prepareSlackMessage(params: { contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`, }); - const envelopeFrom = isDirectMessage ? senderName : roomLabel; + const envelopeFrom = + resolveConversationLabel({ + ChatType: isDirectMessage ? "direct" : "channel", + SenderName: senderName, + GroupSubject: isRoomish ? roomLabel : undefined, + From: slackFrom, + }) ?? (isDirectMessage ? senderName : roomLabel); const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; const body = formatAgentEnvelope({ channel: "Slack", @@ -399,13 +406,16 @@ export async function prepareSlackMessage(params: { const ctxPayload = { Body: combinedBody, + BodyForAgent: combinedBody, RawBody: rawBody, CommandBody: rawBody, + BodyForCommands: rawBody, From: slackFrom, To: slackTo, SessionKey: sessionKey, AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", + ChatType: isDirectMessage ? "direct" : "channel", + ConversationLabel: envelopeFrom, GroupSubject: isRoomish ? roomLabel : undefined, GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined, SenderName: senderName, diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 3d856750e..34a651633 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -18,6 +18,7 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { resolveConversationLabel } from "../../channels/conversation-label.js"; import type { ResolvedSlackAccount } from "../accounts.js"; @@ -337,14 +338,27 @@ export function registerSlackMonitorSlashCommands(params: { const ctxPayload = { Body: prompt, + BodyForAgent: prompt, CommandArgs: commandArgs, + BodyForCommands: prompt, From: isDirectMessage ? `slack:${command.user_id}` : isRoom ? `slack:channel:${command.channel_id}` : `slack:group:${command.channel_id}`, To: `slash:${command.user_id}`, - ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", + ChatType: isDirectMessage ? "direct" : "channel", + ConversationLabel: + resolveConversationLabel({ + ChatType: isDirectMessage ? "direct" : "channel", + SenderName: senderName, + GroupSubject: isRoomish ? roomLabel : undefined, + From: isDirectMessage + ? `slack:${command.user_id}` + : isRoom + ? `slack:channel:${command.channel_id}` + : `slack:group:${command.channel_id}`, + }) ?? (isDirectMessage ? senderName : roomLabel), GroupSubject: isRoomish ? roomLabel : undefined, GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined, SenderName: senderName, diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 5baf1948b..6aaabeeaa 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -324,9 +324,12 @@ export const buildTelegramMessageContext = async ({ }]\n${replyTarget.body}\n[/Replying]` : ""; const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined; + const conversationLabel = isGroup + ? (groupLabel ?? `group:${chatId}`) + : buildSenderLabel(msg, senderId || chatId); const body = formatAgentEnvelope({ channel: "Telegram", - from: isGroup ? (groupLabel ?? `group:${chatId}`) : buildSenderLabel(msg, senderId || chatId), + from: conversationLabel, timestamp: msg.date ? msg.date * 1000 : undefined, body: `${bodyText}${replySuffix}`, }); @@ -357,13 +360,16 @@ export const buildTelegramMessageContext = async ({ const commandBody = normalizeCommandBody(rawBody, { botUsername }); const ctxPayload = { Body: combinedBody, + BodyForAgent: combinedBody, RawBody: rawBody, CommandBody: commandBody, + BodyForCommands: commandBody, From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, To: `telegram:${chatId}`, SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", + ConversationLabel: conversationLabel, GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, SenderName: buildSenderName(msg), diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 4c9cd1543..c6f3e5e5c 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -248,12 +248,18 @@ export const registerTelegramNativeCommands = ({ ].filter((entry): entry is string => Boolean(entry)); const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; + const conversationLabel = isGroup + ? (msg.chat.title ? `${msg.chat.title} id:${chatId}` : `group:${chatId}`) + : (buildSenderName(msg) ?? String(senderId || chatId)); const ctxPayload = { Body: prompt, + BodyForAgent: prompt, CommandArgs: commandArgs, + BodyForCommands: prompt, From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, To: `slash:${senderId || chatId}`, ChatType: isGroup ? "group" : "direct", + ConversationLabel: conversationLabel, GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, SenderName: buildSenderName(msg), diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index c4a1bcdb1..cd7f3a551 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -9,6 +9,7 @@ import { import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import * as replyModule from "../auto-reply/reply.js"; +import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; @@ -583,6 +584,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; + expectInboundContextContract(payload); expect(payload.WasMentioned).toBe(true); expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/); expect(payload.SenderName).toBe("Ada"); @@ -625,6 +627,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; + expectInboundContextContract(payload); expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/); expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); diff --git a/src/web/auto-reply.web-auto-reply.supports-always-group-activation-silent-token-preserves.test.ts b/src/web/auto-reply.web-auto-reply.supports-always-group-activation-silent-token-preserves.test.ts index eac52bbb2..89d06cda7 100644 --- a/src/web/auto-reply.web-auto-reply.supports-always-group-activation-silent-token-preserves.test.ts +++ b/src/web/auto-reply.web-auto-reply.supports-always-group-activation-silent-token-preserves.test.ts @@ -15,6 +15,7 @@ vi.mock("../agents/pi-embedded.js", () => ({ })); import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; import { monitorWebChannel, SILENT_REPLY_TOKEN } from "./auto-reply.js"; import { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js"; @@ -184,6 +185,7 @@ describe("web auto-reply", () => { expect(payload.Body).not.toContain("Alice (+111): first"); expect(payload.Body).not.toContain("[message_id: g-always-1]"); expect(payload.Body).toContain("second"); + expectInboundContextContract(payload); expect(payload.SenderName).toBe("Bob"); expect(payload.SenderE164).toBe("+222"); expect(reply).toHaveBeenCalledTimes(1); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index b1c8d393f..a6f211407 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -22,6 +22,7 @@ import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import type { getChildLogger } from "../../../logging.js"; import type { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { jidToE164, normalizeE164 } from "../../../utils.js"; +import { normalizeChatType } from "../../../channels/chat-type.js"; import { newConnectionId } from "../../reconnect.js"; import { formatError } from "../../session.js"; import { deliverWebReply } from "../deliver-reply.js"; @@ -197,8 +198,10 @@ export async function processMessage(params: { const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: { Body: combinedBody, + BodyForAgent: combinedBody, RawBody: params.msg.body, CommandBody: params.msg.body, + BodyForCommands: params.msg.body, From: params.msg.from, To: params.msg.to, SessionKey: params.route.sessionKey, @@ -210,7 +213,8 @@ export async function processMessage(params: { MediaPath: params.msg.mediaPath, MediaUrl: params.msg.mediaUrl, MediaType: params.msg.mediaType, - ChatType: params.msg.chatType, + ChatType: normalizeChatType(params.msg.chatType) ?? params.msg.chatType, + ConversationLabel: params.msg.chatType === "group" ? conversationId : params.msg.from, GroupSubject: params.msg.groupSubject, GroupMembers: formatGroupMembers({ participants: params.msg.groupParticipants,