From bc49c20434b2b8fdd0cd3e641c5b8a437955eeb3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 05:04:29 +0000 Subject: [PATCH] fix: finalize inbound contexts --- CHANGELOG.md | 1 + extensions/matrix/src/matrix/monitor/index.ts | 29 ++-- .../src/monitor-handler/message-handler.ts | 31 ++-- extensions/zalo/src/monitor.ts | 7 +- extensions/zalouser/src/monitor.ts | 7 +- src/auto-reply/reply/get-reply-run.ts | 4 +- src/auto-reply/reply/get-reply.ts | 3 + src/auto-reply/reply/inbound-context.test.ts | 38 ++++ src/auto-reply/reply/inbound-context.ts | 59 +++++++ src/auto-reply/reply/inbound-text.ts | 6 +- .../message-handler.inbound-contract.test.ts | 90 ++++++++++ .../monitor/message-handler.process.ts | 7 +- src/discord/monitor/native-command.ts | 8 +- ...essages-without-mention-by-default.test.ts | 6 + src/imessage/monitor/monitor-provider.ts | 9 +- src/media-understanding/apply.test.ts | 4 + src/media-understanding/apply.ts | 4 + .../event-handler.inbound-contract.test.ts | 66 +++++++ src/signal/monitor/event-handler.ts | 9 +- .../prepare.inbound-contract.test.ts | 81 +++++++++ src/slack/monitor/message-handler/prepare.ts | 7 +- src/slack/monitor/slash.ts | 9 +- src/telegram/bot-message-context.ts | 7 +- src/telegram/bot-native-commands.ts | 9 +- .../process-message.inbound-contract.test.ts | 54 ++++++ src/web/auto-reply/monitor/process-message.ts | 10 +- test/inbound-contract.providers.test.ts | 163 ++++++++++++++++++ 27 files changed, 645 insertions(+), 83 deletions(-) create mode 100644 src/auto-reply/reply/inbound-context.test.ts create mode 100644 src/auto-reply/reply/inbound-context.ts create mode 100644 src/discord/monitor/message-handler.inbound-contract.test.ts create mode 100644 src/signal/monitor/event-handler.inbound-contract.test.ts create mode 100644 src/slack/monitor/message-handler/prepare.inbound-contract.test.ts create mode 100644 src/web/auto-reply/monitor/process-message.inbound-contract.test.ts create mode 100644 test/inbound-contract.providers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb13cfb59..99b76bcd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ - Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr. - Security: bump `tar` to 7.5.3. - Models: align ZAI thinking toggles. +- iMessage/Signal: include sender metadata for non-queued group messages. (#1059) ## 2026.1.15 diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index def5b88e1..e79b337ca 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -8,6 +8,7 @@ import { hasControlCommand } from "../../../../../src/auto-reply/command-detecti import { shouldHandleTextCommands } from "../../../../../src/auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../../../../../src/auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../../../../../src/auto-reply/reply/dispatch-from-config.js"; +import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionPatterns, @@ -354,16 +355,14 @@ 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, + const ctxPayload = finalizeInboundContext({ + Body: body, + RawBody: bodyText, + CommandBody: bodyText, + From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, + To: `room:${roomId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : "channel", ConversationLabel: envelopeFrom, SenderName: senderName, @@ -382,11 +381,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, - CommandAuthorized: commandAuthorized, - CommandSource: "text" as const, - OriginatingChannel: "matrix" as const, - OriginatingTo: `room:${roomId}`, - }; + CommandAuthorized: commandAuthorized, + CommandSource: "text" as const, + OriginatingChannel: "matrix" as const, + OriginatingTo: `room:${roomId}`, + }); if (isDirectMessage) { const storePath = resolveStorePath(cfg.session?.store, { diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index eab1f6217..d35f69058 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -5,6 +5,7 @@ import { resolveInboundDebounceMs, } from "../../../../src/auto-reply/inbound-debounce.js"; import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js"; +import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; import { buildPendingHistoryContextFromMap, clearHistoryEntries, @@ -381,16 +382,14 @@ 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, + const ctxPayload = finalizeInboundContext({ + Body: combinedBody, + RawBody: rawBody, + CommandBody: rawBody, + From: teamsFrom, + To: teamsTo, + SessionKey: route.sessionKey, + AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group", ConversationLabel: envelopeFrom, GroupSubject: !isDirectMessage ? conversationType : undefined, @@ -400,12 +399,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { Surface: "msteams" as const, MessageSid: activity.id, Timestamp: timestamp?.getTime() ?? Date.now(), - WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention, - CommandAuthorized: true, - OriginatingChannel: "msteams" as const, - OriginatingTo: teamsTo, - ...mediaPayload, - }; + WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention, + CommandAuthorized: true, + OriginatingChannel: "msteams" as const, + OriginatingTo: teamsTo, + ...mediaPayload, + }); if (shouldLogVerbose()) { logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index fff7934d3..1d581e22f 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { ResolvedZaloAccount } from "./accounts.js"; +import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js"; import { ZaloApiError, deleteWebhook, @@ -506,12 +507,10 @@ async function processMessageWithPipeline(params: { body: rawBody, }); - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: body, - BodyForAgent: body, RawBody: rawBody, CommandBody: rawBody, - BodyForCommands: rawBody, From: isGroup ? `group:${chatId}` : `zalo:${senderId}`, To: `zalo:${chatId}`, SessionKey: route.sessionKey, @@ -528,7 +527,7 @@ async function processMessageWithPipeline(params: { MediaUrl: mediaPath, OriginatingChannel: "zalo", OriginatingTo: `zalo:${chatId}`, - }; + }); await deps.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index ff8e65f63..ddf1fd221 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,6 +1,7 @@ import type { ChildProcess } from "node:child_process"; import type { RuntimeEnv } from "../../../src/runtime.js"; +import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js"; import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js"; import { sendMessageZalouser } from "./send.js"; import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js"; @@ -181,12 +182,10 @@ async function processMessage( body: rawBody, }); - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: body, - BodyForAgent: body, RawBody: rawBody, CommandBody: rawBody, - BodyForCommands: rawBody, From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`, To: `zalouser:${chatId}`, SessionKey: route.sessionKey, @@ -200,7 +199,7 @@ async function processMessage( MessageSid: message.msgId ?? `${timestamp}`, OriginatingChannel: "zalouser", OriginatingTo: `zalouser:${chatId}`, - }; + }); await deps.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 870b1c184..1cf023312 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -273,8 +273,10 @@ export async function runPreparedReply( typing.cleanup(); return undefined; } + const isBareNewOrReset = rawBodyTrimmed === "/new" || rawBodyTrimmed === "/reset"; const isBareSessionReset = - isNewSession && baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0; + isNewSession && + ((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset); const baseBodyFinal = isBareSessionReset ? BARE_SESSION_RESET_PROMPT : baseBody; const baseBodyTrimmed = baseBodyFinal.trim(); if (!baseBodyTrimmed) { diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 9bd2cdbdf..648f5da6a 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -17,6 +17,7 @@ import { resolveDefaultModel } from "./directive-handling.js"; import { resolveReplyDirectives } from "./get-reply-directives.js"; import { handleInlineActions } from "./get-reply-inline-actions.js"; import { runPreparedReply } from "./get-reply-run.js"; +import { finalizeInboundContext } from "./inbound-context.js"; import { initSessionState } from "./session.js"; import { stageSandboxMedia } from "./stage-sandbox-media.js"; import { createTypingController } from "./typing.js"; @@ -74,6 +75,8 @@ export async function getReplyFromConfig( }); opts?.onTypingController?.(typing); + finalizeInboundContext(ctx); + await applyMediaUnderstanding({ ctx, cfg, diff --git a/src/auto-reply/reply/inbound-context.test.ts b/src/auto-reply/reply/inbound-context.test.ts new file mode 100644 index 000000000..2cfbbb805 --- /dev/null +++ b/src/auto-reply/reply/inbound-context.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import type { MsgContext } from "../templating.js"; +import { finalizeInboundContext } from "./inbound-context.js"; + +describe("finalizeInboundContext", () => { + it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => { + const ctx: MsgContext = { + Body: "a\\nb\r\nc", + RawBody: "raw\\nline", + ChatType: "room", + From: "group:123@g.us", + GroupSubject: "Test", + }; + + const out = finalizeInboundContext(ctx); + expect(out.Body).toBe("a\nb\nc"); + expect(out.RawBody).toBe("raw\nline"); + expect(out.BodyForAgent).toBe("a\nb\nc"); + expect(out.BodyForCommands).toBe("raw\nline"); + expect(out.ChatType).toBe("channel"); + expect(out.ConversationLabel).toContain("Test"); + }); + + it("can force BodyForCommands to follow updated CommandBody", () => { + const ctx: MsgContext = { + Body: "base", + BodyForCommands: "", + CommandBody: "say hi", + From: "signal:+15550001111", + ChatType: "direct", + }; + + finalizeInboundContext(ctx, { forceBodyForCommands: true }); + expect(ctx.BodyForCommands).toBe("say hi"); + }); +}); + diff --git a/src/auto-reply/reply/inbound-context.ts b/src/auto-reply/reply/inbound-context.ts new file mode 100644 index 000000000..fb97ec646 --- /dev/null +++ b/src/auto-reply/reply/inbound-context.ts @@ -0,0 +1,59 @@ +import { normalizeChatType } from "../../channels/chat-type.js"; +import { resolveConversationLabel } from "../../channels/conversation-label.js"; +import type { MsgContext } from "../templating.js"; +import { normalizeInboundTextNewlines } from "./inbound-text.js"; + +export type FinalizeInboundContextOptions = { + forceBodyForAgent?: boolean; + forceBodyForCommands?: boolean; + forceChatType?: boolean; + forceConversationLabel?: boolean; +}; + +function normalizeTextField(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + return normalizeInboundTextNewlines(value); +} + +export function finalizeInboundContext>( + ctx: T, + opts: FinalizeInboundContextOptions = {}, +): T & MsgContext { + const normalized = ctx as T & MsgContext; + + normalized.Body = normalizeInboundTextNewlines( + typeof normalized.Body === "string" ? normalized.Body : "", + ); + normalized.RawBody = normalizeTextField(normalized.RawBody); + normalized.CommandBody = normalizeTextField(normalized.CommandBody); + normalized.Transcript = normalizeTextField(normalized.Transcript); + normalized.ThreadStarterBody = normalizeTextField(normalized.ThreadStarterBody); + + const chatType = normalizeChatType(normalized.ChatType); + if (chatType && (opts.forceChatType || normalized.ChatType !== chatType)) { + normalized.ChatType = chatType; + } + + const bodyForAgentSource = opts.forceBodyForAgent + ? normalized.Body + : (normalized.BodyForAgent ?? normalized.Body); + normalized.BodyForAgent = normalizeInboundTextNewlines(bodyForAgentSource); + + const bodyForCommandsSource = opts.forceBodyForCommands + ? (normalized.CommandBody ?? normalized.RawBody ?? normalized.Body) + : (normalized.BodyForCommands ?? + normalized.CommandBody ?? + normalized.RawBody ?? + normalized.Body); + normalized.BodyForCommands = normalizeInboundTextNewlines(bodyForCommandsSource); + + const explicitLabel = normalized.ConversationLabel?.trim(); + if (opts.forceConversationLabel || !explicitLabel) { + const resolved = resolveConversationLabel(normalized)?.trim(); + if (resolved) normalized.ConversationLabel = resolved; + } else { + normalized.ConversationLabel = explicitLabel; + } + + return normalized; +} diff --git a/src/auto-reply/reply/inbound-text.ts b/src/auto-reply/reply/inbound-text.ts index d87b1eb9e..dd17752b4 100644 --- a/src/auto-reply/reply/inbound-text.ts +++ b/src/auto-reply/reply/inbound-text.ts @@ -1,7 +1,3 @@ export function normalizeInboundTextNewlines(input: string): string { - const text = input.replaceAll("\r\n", "\n").replaceAll("\r", "\n"); - if (text.includes("\n")) return text; - if (!text.includes("\\n")) return text; - return text.replaceAll("\\n", "\n"); + return input.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\\n", "\n"); } - diff --git a/src/discord/monitor/message-handler.inbound-contract.test.ts b/src/discord/monitor/message-handler.inbound-contract.test.ts new file mode 100644 index 000000000..029d9d5b1 --- /dev/null +++ b/src/discord/monitor/message-handler.inbound-contract.test.ts @@ -0,0 +1,90 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import type { MsgContext } from "../../auto-reply/templating.js"; +import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; + +let capturedCtx: MsgContext | undefined; + +vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ + dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => { + capturedCtx = params.ctx; + return { queuedFinal: false, counts: { tool: 0, block: 0 } }; + }), +})); + +import { processDiscordMessage } from "./message-handler.process.js"; + +describe("discord processDiscordMessage inbound contract", () => { + it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => { + capturedCtx = undefined; + + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-")); + const storePath = path.join(dir, "sessions.json"); + + await processDiscordMessage({ + cfg: { messages: {}, session: { store: storePath } } as any, + discordConfig: {} as any, + accountId: "default", + token: "token", + runtime: { log: () => {}, error: () => {} } as any, + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1024, + textLimit: 4000, + replyToMode: "off", + ackReactionScope: "direct", + groupPolicy: "open", + data: { guild: null } as any, + client: { rest: {} } as any, + message: { + id: "m1", + channelId: "c1", + timestamp: new Date().toISOString(), + attachments: [], + } as any, + author: { + id: "U1", + username: "alice", + discriminator: "0", + globalName: "Alice", + } as any, + channelInfo: null, + channelName: undefined, + isGuildMessage: false, + isDirectMessage: true, + isGroupDm: false, + commandAuthorized: true, + baseText: "hi", + messageText: "hi", + wasMentioned: false, + shouldRequireMention: false, + canDetectMention: false, + effectiveWasMentioned: false, + threadChannel: null, + threadParentId: undefined, + threadParentName: undefined, + threadParentType: undefined, + threadName: undefined, + displayChannelSlug: "", + guildInfo: null, + guildSlug: "", + channelConfig: null, + baseSessionKey: "agent:main:discord:dm:U1", + route: { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: "agent:main:discord:dm:U1", + mainSessionKey: "agent:main:main", + } as any, + } as any); + + expect(capturedCtx).toBeTruthy(); + expectInboundContextContract(capturedCtx!); + }); +}); + diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index a528c3817..3a88ece72 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -14,6 +14,7 @@ import { buildPendingHistoryContextFromMap, clearHistoryEntries, } from "../../auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { resolveStorePath, updateLastRoute } from "../../config/sessions.js"; @@ -219,12 +220,10 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) return; } - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: combinedBody, - BodyForAgent: combinedBody, RawBody: baseText, CommandBody: baseText, - BodyForCommands: baseText, From: effectiveFrom, To: effectiveTo, SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey, @@ -253,7 +252,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) // Originating channel for reply routing. OriginatingChannel: "discord" as const, OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget, - }; + }); if (isDirectMessage) { const sessionCfg = cfg.session; diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 33bd9109e..0e99eeed4 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -30,6 +30,7 @@ import type { NativeCommandSpec, } from "../../auto-reply/commands-registry.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; +import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ClawdbotConfig, loadConfig } from "../../config/config.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; @@ -570,11 +571,10 @@ async function dispatchDiscordCommandInteraction(params: { }, }); const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId; - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: prompt, - BodyForAgent: prompt, + RawBody: prompt, CommandBody: prompt, - BodyForCommands: prompt, CommandArgs: commandArgs, From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`, To: `slash:${user.id}`, @@ -607,7 +607,7 @@ async function dispatchDiscordCommandInteraction(params: { Timestamp: Date.now(), CommandAuthorized: commandAuthorized, CommandSource: "native" as const, - }; + }); let didReply = false; await dispatchReplyWithDispatcher({ diff --git a/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts b/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts index 5039212e1..4eef2218d 100644 --- a/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts +++ b/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts @@ -378,6 +378,12 @@ describe("monitorIMessageProvider", () => { closeResolve?.(); await run; + expect(replyMock).toHaveBeenCalledOnce(); + const ctx = replyMock.mock.calls[0]?.[0] as { Body?: string; ChatType?: string }; + expect(ctx.ChatType).toBe("group"); + expect(String(ctx.Body ?? "")).toContain("[from:"); + expect(String(ctx.Body ?? "")).toContain("+15550002222"); + expect(sendMock).toHaveBeenCalledWith( "chat_id:42", "yo", diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 924a46dc2..e26b34020 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -17,6 +17,7 @@ import { resolveInboundDebounceMs, } from "../../auto-reply/inbound-debounce.js"; import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; +import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { buildPendingHistoryContextFromMap, clearHistoryEntries, @@ -26,6 +27,7 @@ import { } from "../../auto-reply/reply/history.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; +import { formatInboundBodyWithSenderMeta } from "../../auto-reply/reply/inbound-sender-meta.js"; import { loadConfig } from "../../config/config.js"; import { resolveChannelGroupPolicy, @@ -387,12 +389,10 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P } const imessageTo = (isGroup ? chatTarget : undefined) || `imessage:${sender}`; - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: combinedBody, - BodyForAgent: combinedBody, RawBody: bodyText, CommandBody: bodyText, - BodyForCommands: bodyText, From: isGroup ? `group:${chatId}` : `imessage:${sender}`, To: imessageTo, SessionKey: route.sessionKey, @@ -416,7 +416,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P // Originating channel for reply routing. OriginatingChannel: "imessage" as const, OriginatingTo: imessageTo, - }; + }); + ctxPayload.Body = formatInboundBodyWithSenderMeta({ ctx: ctxPayload, body: ctxPayload.Body }); if (!isGroup) { const sessionCfg = cfg.session; diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index b753cac31..344bb9067 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -81,6 +81,8 @@ describe("applyMediaUnderstanding", () => { expect(ctx.Body).toBe("[Audio]\nTranscript:\ntranscribed text"); expect(ctx.CommandBody).toBe("transcribed text"); expect(ctx.RawBody).toBe("transcribed text"); + expect(ctx.BodyForAgent).toBe(ctx.Body); + expect(ctx.BodyForCommands).toBe("transcribed text"); }); it("handles URL-only attachments for audio transcription", async () => { @@ -254,6 +256,8 @@ describe("applyMediaUnderstanding", () => { expect(ctx.Body).toBe("[Image]\nUser text:\nshow Dom\nDescription:\nimage description"); expect(ctx.CommandBody).toBe("show Dom"); expect(ctx.RawBody).toBe("show Dom"); + expect(ctx.BodyForAgent).toBe(ctx.Body); + expect(ctx.BodyForCommands).toBe("show Dom"); }); it("uses shared media models list when capability config is missing", async () => { diff --git a/src/media-understanding/apply.ts b/src/media-understanding/apply.ts index 1b05348a6..e5a756e70 100644 --- a/src/media-understanding/apply.ts +++ b/src/media-understanding/apply.ts @@ -1,7 +1,10 @@ import type { ClawdbotConfig } from "../config/config.js"; import type { MsgContext } from "../auto-reply/templating.js"; import { applyTemplate } from "../auto-reply/templating.js"; +import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { resolveApiKeyForProvider } from "../agents/model-auth.js"; +import { ensureClawdbotModelsJson } from "../agents/models-config.js"; +import { minimaxUnderstandImage } from "../agents/minimax-vlm.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { runExec } from "../process/exec.js"; import type { @@ -449,6 +452,7 @@ export async function applyMediaUnderstanding(params: { ctx.RawBody = originalUserText; } ctx.MediaUnderstanding = [...(ctx.MediaUnderstanding ?? []), ...outputs]; + finalizeInboundContext(ctx, { forceBodyForAgent: true, forceBodyForCommands: true }); } return { diff --git a/src/signal/monitor/event-handler.inbound-contract.test.ts b/src/signal/monitor/event-handler.inbound-contract.test.ts new file mode 100644 index 000000000..2e8dff16a --- /dev/null +++ b/src/signal/monitor/event-handler.inbound-contract.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { MsgContext } from "../../auto-reply/templating.js"; +import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; + +let capturedCtx: MsgContext | undefined; + +vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ + dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => { + capturedCtx = params.ctx; + return { queuedFinal: false, counts: { tool: 0, block: 0 } }; + }), +})); + +import { createSignalEventHandler } from "./event-handler.js"; + +describe("signal createSignalEventHandler inbound contract", () => { + it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => { + capturedCtx = undefined; + + const handler = createSignalEventHandler({ + runtime: { log: () => {}, error: () => {} } as any, + cfg: { messages: { inbound: { debounceMs: 0 } } } as any, + baseUrl: "http://localhost", + accountId: "default", + historyLimit: 0, + groupHistories: new Map(), + textLimit: 4000, + dmPolicy: "open", + allowFrom: ["*"], + groupAllowFrom: ["*"], + groupPolicy: "open", + reactionMode: "off", + reactionAllowlist: [], + mediaMaxBytes: 1024, + ignoreAttachments: true, + fetchAttachment: async () => null, + deliverReplies: async () => {}, + resolveSignalReactionTargets: () => [], + isSignalReactionMessage: () => false as any, + shouldEmitSignalReactionNotification: () => false, + buildSignalReactionSystemEventText: () => "reaction", + }); + + await handler({ + event: "receive", + data: JSON.stringify({ + envelope: { + sourceNumber: "+15550001111", + sourceName: "Alice", + timestamp: 1700000000000, + dataMessage: { + message: "hi", + attachments: [], + groupInfo: { groupId: "g1", groupName: "Test Group" }, + }, + }, + }), + }); + + expect(capturedCtx).toBeTruthy(); + expectInboundContextContract(capturedCtx!); + expect(String(capturedCtx?.Body ?? "")).toContain("[from:"); + expect(String(capturedCtx?.Body ?? "")).toContain("Alice"); + }); +}); diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 4e42d0ca6..c70934da2 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -18,6 +18,8 @@ import { buildPendingHistoryContextFromMap, clearHistoryEntries, } from "../../auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; +import { formatInboundBodyWithSenderMeta } from "../../auto-reply/reply/inbound-sender-meta.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { resolveStorePath, updateLastRoute } from "../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; @@ -102,12 +104,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }, }); const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`; - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: combinedBody, - BodyForAgent: combinedBody, RawBody: entry.bodyText, CommandBody: entry.bodyText, - BodyForCommands: entry.bodyText, From: entry.isGroup ? `group:${entry.groupId ?? "unknown"}` : `signal:${entry.senderRecipient}`, @@ -129,7 +129,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { CommandAuthorized: entry.commandAuthorized, OriginatingChannel: "signal" as const, OriginatingTo: signalTo, - }; + }); + ctxPayload.Body = formatInboundBodyWithSenderMeta({ ctx: ctxPayload, body: ctxPayload.Body }); if (!entry.isGroup) { const sessionCfg = deps.cfg.session; diff --git a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts new file mode 100644 index 000000000..2045abf46 --- /dev/null +++ b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts @@ -0,0 +1,81 @@ +import type { App } from "@slack/bolt"; +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../../../config/config.js"; +import type { RuntimeEnv } from "../../../runtime.js"; +import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; +import type { ResolvedSlackAccount } from "../../accounts.js"; +import type { SlackMessageEvent } from "../../types.js"; +import { createSlackMonitorContext } from "../context.js"; +import { prepareSlackMessage } from "./prepare.js"; + +describe("slack prepareSlackMessage inbound contract", () => { + it("produces a finalized MsgContext", async () => { + const slackCtx = createSlackMonitorContext({ + cfg: { + channels: { slack: { enabled: true } }, + } as ClawdbotConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "clawd", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1024, + removeAckAfterReply: false, + }); + slackCtx.resolveUserName = async () => ({ name: "Alice" } as any); + + const account: ResolvedSlackAccount = { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + config: {}, + }; + + const message: SlackMessageEvent = { + channel: "D123", + channel_type: "im", + user: "U1", + text: "hi", + ts: "1.000", + } as SlackMessageEvent; + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + expectInboundContextContract(prepared!.ctxPayload as any); + }); +}); + diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 7855bced8..a84f19402 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -6,6 +6,7 @@ import { buildPendingHistoryContextFromMap, recordPendingHistoryEntry, } from "../../../auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../../auto-reply/reply/mentions.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js"; @@ -404,12 +405,10 @@ export async function prepareSlackMessage(params: { } } - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: combinedBody, - BodyForAgent: combinedBody, RawBody: rawBody, CommandBody: rawBody, - BodyForCommands: rawBody, From: slackFrom, To: slackTo, SessionKey: sessionKey, @@ -435,7 +434,7 @@ export async function prepareSlackMessage(params: { CommandAuthorized: commandAuthorized, OriginatingChannel: "slack" as const, OriginatingTo: slackTo, - } satisfies Record; + }) satisfies Record; const replyTarget = ctxPayload.To ?? undefined; if (!replyTarget) return null; diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index 34a651633..1b4123eac 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -10,6 +10,7 @@ import { } from "../../auto-reply/commands-registry.js"; import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; +import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; import { danger, logVerbose } from "../../globals.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; @@ -336,11 +337,11 @@ export function registerSlackMonitorSlashCommands(params: { const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: prompt, - BodyForAgent: prompt, + RawBody: prompt, + CommandBody: prompt, CommandArgs: commandArgs, - BodyForCommands: prompt, From: isDirectMessage ? `slack:${command.user_id}` : isRoom @@ -375,7 +376,7 @@ export function registerSlackMonitorSlashCommands(params: { CommandAuthorized: commandAuthorized, OriginatingChannel: "slack" as const, OriginatingTo: `user:${command.user_id}`, - }; + }); const { counts } = await dispatchReplyWithDispatcher({ ctx: ctxPayload, diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 6aaabeeaa..5053c2e2f 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -7,6 +7,7 @@ import { buildPendingHistoryContextFromMap, recordPendingHistoryEntry, } from "../auto-reply/reply/history.js"; +import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; @@ -358,12 +359,10 @@ export const buildTelegramMessageContext = async ({ const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; const commandBody = normalizeCommandBody(rawBody, { botUsername }); - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: combinedBody, - BodyForAgent: combinedBody, RawBody: rawBody, CommandBody: commandBody, - BodyForCommands: commandBody, From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, To: `telegram:${chatId}`, SessionKey: route.sessionKey, @@ -399,7 +398,7 @@ export const buildTelegramMessageContext = async ({ // Originating channel for reply routing. OriginatingChannel: "telegram" as const, OriginatingTo: `telegram:${chatId}`, - }; + }); if (replyTarget && shouldLogVerbose()) { const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index c6f3e5e5c..5d5a1d4bf 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -13,6 +13,7 @@ import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import type { CommandArgs } from "../auto-reply/commands-registry.js"; import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; +import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { danger, logVerbose } from "../globals.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { deliverReplies } from "./bot/delivery.js"; @@ -251,11 +252,11 @@ export const registerTelegramNativeCommands = ({ const conversationLabel = isGroup ? (msg.chat.title ? `${msg.chat.title} id:${chatId}` : `group:${chatId}`) : (buildSenderName(msg) ?? String(senderId || chatId)); - const ctxPayload = { + const ctxPayload = finalizeInboundContext({ Body: prompt, - BodyForAgent: prompt, + RawBody: prompt, + CommandBody: prompt, CommandArgs: commandArgs, - BodyForCommands: prompt, From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, To: `slash:${senderId || chatId}`, ChatType: isGroup ? "group" : "direct", @@ -275,7 +276,7 @@ export const registerTelegramNativeCommands = ({ CommandTargetSessionKey: route.sessionKey, MessageThreadId: resolvedThreadId, IsForum: isForum, - }; + }); const disableBlockStreaming = typeof telegramCfg.blockStreaming === "boolean" diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts new file mode 100644 index 000000000..c43de3449 --- /dev/null +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; + +import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; + +let capturedCtx: unknown; + +vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ + dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: unknown }) => { + capturedCtx = params.ctx; + return { queuedFinal: false }; + }), +})); + +import { processMessage } from "./process-message.js"; + +describe("web processMessage inbound contract", () => { + it("passes a finalized MsgContext to the dispatcher", async () => { + capturedCtx = undefined; + + await processMessage({ + cfg: { messages: {} } as any, + msg: { + id: "msg1", + from: "123@g.us", + to: "+15550001111", + chatType: "group", + body: "hi", + senderName: "Alice", + senderJid: "alice@s.whatsapp.net", + senderE164: "+15550002222", + groupSubject: "Test Group", + groupParticipants: [], + } as any, + route: { agentId: "main", accountId: "default", sessionKey: "agent:main:whatsapp:group:123" } as any, + groupHistoryKey: "123@g.us", + groupHistories: new Map(), + groupMemberNames: new Map(), + connectionId: "conn", + verbose: false, + maxMediaBytes: 1, + replyResolver: (async () => undefined) as any, + replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, + backgroundTasks: new Set(), + rememberSentText: (_text: string | undefined, _opts: unknown) => {}, + echoHas: () => false, + echoForget: () => {}, + buildCombinedEchoKey: () => "echo", + groupHistory: [], + } as any); + + expect(capturedCtx).toBeTruthy(); + expectInboundContextContract(capturedCtx as any); + }); +}); diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index a6f211407..314290eee 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -16,13 +16,13 @@ import { import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js"; import type { getReplyFromConfig } from "../../../auto-reply/reply.js"; import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js"; import { toLocationContext } from "../../../channels/location.js"; import type { loadConfig } from "../../../config/config.js"; 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"; @@ -196,12 +196,10 @@ export async function processMessage(params: { }; const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ - ctx: { + ctx: finalizeInboundContext({ 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, @@ -213,7 +211,7 @@ export async function processMessage(params: { MediaPath: params.msg.mediaPath, MediaUrl: params.msg.mediaUrl, MediaType: params.msg.mediaType, - ChatType: normalizeChatType(params.msg.chatType) ?? params.msg.chatType, + ChatType: params.msg.chatType, ConversationLabel: params.msg.chatType === "group" ? conversationId : params.msg.from, GroupSubject: params.msg.groupSubject, GroupMembers: formatGroupMembers({ @@ -230,7 +228,7 @@ export async function processMessage(params: { Surface: "whatsapp", OriginatingChannel: "whatsapp", OriginatingTo: params.msg.from, - }, + }), cfg: params.cfg, replyResolver: params.replyResolver, dispatcherOptions: { diff --git a/test/inbound-contract.providers.test.ts b/test/inbound-contract.providers.test.ts new file mode 100644 index 000000000..791046e1b --- /dev/null +++ b/test/inbound-contract.providers.test.ts @@ -0,0 +1,163 @@ +import { describe, it } from "vitest"; + +import type { MsgContext } from "../src/auto-reply/templating.js"; +import { finalizeInboundContext } from "../src/auto-reply/reply/inbound-context.js"; +import { expectInboundContextContract } from "./helpers/inbound-contract.js"; + +describe("inbound context contract (providers + extensions)", () => { + const cases: Array<{ name: string; ctx: MsgContext }> = [ + { + name: "whatsapp group", + ctx: { + Provider: "whatsapp", + Surface: "whatsapp", + ChatType: "group", + From: "123@g.us", + To: "+15550001111", + Body: "[WhatsApp 123@g.us] hi", + RawBody: "hi", + CommandBody: "hi", + SenderName: "Alice", + }, + }, + { + name: "telegram group", + ctx: { + Provider: "telegram", + Surface: "telegram", + ChatType: "group", + From: "group:123", + To: "telegram:123", + Body: "[Telegram group:123] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Telegram Group", + SenderName: "Alice", + }, + }, + { + name: "slack channel", + ctx: { + Provider: "slack", + Surface: "slack", + ChatType: "channel", + From: "slack:channel:C123", + To: "channel:C123", + Body: "[Slack #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "discord channel", + ctx: { + Provider: "discord", + Surface: "discord", + ChatType: "channel", + From: "group:123", + To: "channel:123", + Body: "[Discord #general] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "signal dm", + ctx: { + Provider: "signal", + Surface: "signal", + ChatType: "direct", + From: "signal:+15550001111", + To: "signal:+15550002222", + Body: "[Signal] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "imessage group", + ctx: { + Provider: "imessage", + Surface: "imessage", + ChatType: "group", + From: "group:chat_id:123", + To: "chat_id:123", + Body: "[iMessage Group] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "iMessage Group", + SenderName: "Alice", + }, + }, + { + name: "matrix channel", + ctx: { + Provider: "matrix", + Surface: "matrix", + ChatType: "channel", + From: "matrix:channel:!room:example.org", + To: "room:!room:example.org", + Body: "[Matrix] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "#general", + SenderName: "Alice", + }, + }, + { + name: "msteams channel", + ctx: { + Provider: "msteams", + Surface: "msteams", + ChatType: "channel", + From: "msteams:channel:19:abc@thread.tacv2", + To: "msteams:channel:19:abc@thread.tacv2", + Body: "[Teams] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Teams Channel", + SenderName: "Alice", + }, + }, + { + name: "zalo dm", + ctx: { + Provider: "zalo", + Surface: "zalo", + ChatType: "direct", + From: "zalo:123", + To: "zalo:123", + Body: "[Zalo] hi", + RawBody: "hi", + CommandBody: "hi", + }, + }, + { + name: "zalouser group", + ctx: { + Provider: "zalouser", + Surface: "zalouser", + ChatType: "group", + From: "group:123", + To: "zalouser:123", + Body: "[Zalo Personal] hi", + RawBody: "hi", + CommandBody: "hi", + GroupSubject: "Zalouser Group", + SenderName: "Alice", + }, + }, + ]; + + for (const entry of cases) { + it(entry.name, () => { + const ctx = finalizeInboundContext({ ...entry.ctx }); + expectInboundContextContract(ctx); + }); + } +}); +