fix: finalize inbound contexts

This commit is contained in:
Peter Steinberger
2026-01-17 05:04:29 +00:00
parent 4b085f23e0
commit bc49c20434
27 changed files with 645 additions and 83 deletions

View File

@@ -53,6 +53,7 @@
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr. - Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
- Security: bump `tar` to 7.5.3. - Security: bump `tar` to 7.5.3.
- Models: align ZAI thinking toggles. - Models: align ZAI thinking toggles.
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
## 2026.1.15 ## 2026.1.15

View File

@@ -8,6 +8,7 @@ import { hasControlCommand } from "../../../../../src/auto-reply/command-detecti
import { shouldHandleTextCommands } from "../../../../../src/auto-reply/commands-registry.js"; import { shouldHandleTextCommands } from "../../../../../src/auto-reply/commands-registry.js";
import { formatAgentEnvelope } from "../../../../../src/auto-reply/envelope.js"; import { formatAgentEnvelope } from "../../../../../src/auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../../../../src/auto-reply/reply/dispatch-from-config.js"; import { dispatchReplyFromConfig } from "../../../../../src/auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js";
import { import {
buildMentionRegexes, buildMentionRegexes,
matchesMentionPatterns, matchesMentionPatterns,
@@ -354,16 +355,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}); });
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: body, Body: body,
BodyForAgent: body, RawBody: bodyText,
RawBody: bodyText, CommandBody: bodyText,
CommandBody: bodyText, From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
BodyForCommands: bodyText, To: `room:${roomId}`,
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, SessionKey: route.sessionKey,
To: `room:${roomId}`, AccountId: route.accountId,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel", ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: envelopeFrom, ConversationLabel: envelopeFrom,
SenderName: senderName, SenderName: senderName,
@@ -382,11 +381,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
MediaPath: media?.path, MediaPath: media?.path,
MediaType: media?.contentType, MediaType: media?.contentType,
MediaUrl: media?.path, MediaUrl: media?.path,
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
CommandSource: "text" as const, CommandSource: "text" as const,
OriginatingChannel: "matrix" as const, OriginatingChannel: "matrix" as const,
OriginatingTo: `room:${roomId}`, OriginatingTo: `room:${roomId}`,
}; });
if (isDirectMessage) { if (isDirectMessage) {
const storePath = resolveStorePath(cfg.session?.store, { const storePath = resolveStorePath(cfg.session?.store, {

View File

@@ -5,6 +5,7 @@ import {
resolveInboundDebounceMs, resolveInboundDebounceMs,
} from "../../../../src/auto-reply/inbound-debounce.js"; } from "../../../../src/auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js"; import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js";
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntries, clearHistoryEntries,
@@ -381,16 +382,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}); });
} }
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
BodyForAgent: combinedBody, RawBody: rawBody,
RawBody: rawBody, CommandBody: rawBody,
CommandBody: rawBody, From: teamsFrom,
BodyForCommands: rawBody, To: teamsTo,
From: teamsFrom, SessionKey: route.sessionKey,
To: teamsTo, AccountId: route.accountId,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group", ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group",
ConversationLabel: envelopeFrom, ConversationLabel: envelopeFrom,
GroupSubject: !isDirectMessage ? conversationType : undefined, GroupSubject: !isDirectMessage ? conversationType : undefined,
@@ -400,12 +399,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
Surface: "msteams" as const, Surface: "msteams" as const,
MessageSid: activity.id, MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(), Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention, WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
CommandAuthorized: true, CommandAuthorized: true,
OriginatingChannel: "msteams" as const, OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo, OriginatingTo: teamsTo,
...mediaPayload, ...mediaPayload,
}; });
if (shouldLogVerbose()) { if (shouldLogVerbose()) {
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);

View File

@@ -1,6 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { ResolvedZaloAccount } from "./accounts.js"; import type { ResolvedZaloAccount } from "./accounts.js";
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
import { import {
ZaloApiError, ZaloApiError,
deleteWebhook, deleteWebhook,
@@ -506,12 +507,10 @@ async function processMessageWithPipeline(params: {
body: rawBody, body: rawBody,
}); });
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: body, Body: body,
BodyForAgent: body,
RawBody: rawBody, RawBody: rawBody,
CommandBody: rawBody, CommandBody: rawBody,
BodyForCommands: rawBody,
From: isGroup ? `group:${chatId}` : `zalo:${senderId}`, From: isGroup ? `group:${chatId}` : `zalo:${senderId}`,
To: `zalo:${chatId}`, To: `zalo:${chatId}`,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
@@ -528,7 +527,7 @@ async function processMessageWithPipeline(params: {
MediaUrl: mediaPath, MediaUrl: mediaPath,
OriginatingChannel: "zalo", OriginatingChannel: "zalo",
OriginatingTo: `zalo:${chatId}`, OriginatingTo: `zalo:${chatId}`,
}; });
await deps.dispatchReplyWithBufferedBlockDispatcher({ await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,

View File

@@ -1,6 +1,7 @@
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import type { RuntimeEnv } from "../../../src/runtime.js"; 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 { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
import { sendMessageZalouser } from "./send.js"; import { sendMessageZalouser } from "./send.js";
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js"; import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
@@ -181,12 +182,10 @@ async function processMessage(
body: rawBody, body: rawBody,
}); });
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: body, Body: body,
BodyForAgent: body,
RawBody: rawBody, RawBody: rawBody,
CommandBody: rawBody, CommandBody: rawBody,
BodyForCommands: rawBody,
From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`, From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`,
To: `zalouser:${chatId}`, To: `zalouser:${chatId}`,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
@@ -200,7 +199,7 @@ async function processMessage(
MessageSid: message.msgId ?? `${timestamp}`, MessageSid: message.msgId ?? `${timestamp}`,
OriginatingChannel: "zalouser", OriginatingChannel: "zalouser",
OriginatingTo: `zalouser:${chatId}`, OriginatingTo: `zalouser:${chatId}`,
}; });
await deps.dispatchReplyWithBufferedBlockDispatcher({ await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,

View File

@@ -273,8 +273,10 @@ export async function runPreparedReply(
typing.cleanup(); typing.cleanup();
return undefined; return undefined;
} }
const isBareNewOrReset = rawBodyTrimmed === "/new" || rawBodyTrimmed === "/reset";
const isBareSessionReset = 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 baseBodyFinal = isBareSessionReset ? BARE_SESSION_RESET_PROMPT : baseBody;
const baseBodyTrimmed = baseBodyFinal.trim(); const baseBodyTrimmed = baseBodyFinal.trim();
if (!baseBodyTrimmed) { if (!baseBodyTrimmed) {

View File

@@ -17,6 +17,7 @@ import { resolveDefaultModel } from "./directive-handling.js";
import { resolveReplyDirectives } from "./get-reply-directives.js"; import { resolveReplyDirectives } from "./get-reply-directives.js";
import { handleInlineActions } from "./get-reply-inline-actions.js"; import { handleInlineActions } from "./get-reply-inline-actions.js";
import { runPreparedReply } from "./get-reply-run.js"; import { runPreparedReply } from "./get-reply-run.js";
import { finalizeInboundContext } from "./inbound-context.js";
import { initSessionState } from "./session.js"; import { initSessionState } from "./session.js";
import { stageSandboxMedia } from "./stage-sandbox-media.js"; import { stageSandboxMedia } from "./stage-sandbox-media.js";
import { createTypingController } from "./typing.js"; import { createTypingController } from "./typing.js";
@@ -74,6 +75,8 @@ export async function getReplyFromConfig(
}); });
opts?.onTypingController?.(typing); opts?.onTypingController?.(typing);
finalizeInboundContext(ctx);
await applyMediaUnderstanding({ await applyMediaUnderstanding({
ctx, ctx,
cfg, cfg,

View File

@@ -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: "<media:audio>",
CommandBody: "say hi",
From: "signal:+15550001111",
ChatType: "direct",
};
finalizeInboundContext(ctx, { forceBodyForCommands: true });
expect(ctx.BodyForCommands).toBe("say hi");
});
});

View File

@@ -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<T extends Record<string, unknown>>(
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;
}

View File

@@ -1,7 +1,3 @@
export function normalizeInboundTextNewlines(input: string): string { export function normalizeInboundTextNewlines(input: string): string {
const text = input.replaceAll("\r\n", "\n").replaceAll("\r", "\n"); return input.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\\n", "\n");
if (text.includes("\n")) return text;
if (!text.includes("\\n")) return text;
return text.replaceAll("\\n", "\n");
} }

View File

@@ -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!);
});
});

View File

@@ -14,6 +14,7 @@ import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntries, clearHistoryEntries,
} from "../../auto-reply/reply/history.js"; } 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 { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveStorePath, updateLastRoute } from "../../config/sessions.js"; import { resolveStorePath, updateLastRoute } from "../../config/sessions.js";
@@ -219,12 +220,10 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
return; return;
} }
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
BodyForAgent: combinedBody,
RawBody: baseText, RawBody: baseText,
CommandBody: baseText, CommandBody: baseText,
BodyForCommands: baseText,
From: effectiveFrom, From: effectiveFrom,
To: effectiveTo, To: effectiveTo,
SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey, SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
@@ -253,7 +252,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
// Originating channel for reply routing. // Originating channel for reply routing.
OriginatingChannel: "discord" as const, OriginatingChannel: "discord" as const,
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget, OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
}; });
if (isDirectMessage) { if (isDirectMessage) {
const sessionCfg = cfg.session; const sessionCfg = cfg.session;

View File

@@ -30,6 +30,7 @@ import type {
NativeCommandSpec, NativeCommandSpec,
} from "../../auto-reply/commands-registry.js"; } from "../../auto-reply/commands-registry.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.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 { ReplyPayload } from "../../auto-reply/types.js";
import type { ClawdbotConfig, loadConfig } from "../../config/config.js"; import type { ClawdbotConfig, loadConfig } from "../../config/config.js";
import { buildPairingReply } from "../../pairing/pairing-messages.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 conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: prompt, Body: prompt,
BodyForAgent: prompt, RawBody: prompt,
CommandBody: prompt, CommandBody: prompt,
BodyForCommands: prompt,
CommandArgs: commandArgs, CommandArgs: commandArgs,
From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`, From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`,
To: `slash:${user.id}`, To: `slash:${user.id}`,
@@ -607,7 +607,7 @@ async function dispatchDiscordCommandInteraction(params: {
Timestamp: Date.now(), Timestamp: Date.now(),
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
CommandSource: "native" as const, CommandSource: "native" as const,
}; });
let didReply = false; let didReply = false;
await dispatchReplyWithDispatcher({ await dispatchReplyWithDispatcher({

View File

@@ -378,6 +378,12 @@ describe("monitorIMessageProvider", () => {
closeResolve?.(); closeResolve?.();
await run; 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( expect(sendMock).toHaveBeenCalledWith(
"chat_id:42", "chat_id:42",
"yo", "yo",

View File

@@ -17,6 +17,7 @@ import {
resolveInboundDebounceMs, resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js"; } from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntries, clearHistoryEntries,
@@ -26,6 +27,7 @@ import {
} from "../../auto-reply/reply/history.js"; } from "../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.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 { loadConfig } from "../../config/config.js";
import { import {
resolveChannelGroupPolicy, resolveChannelGroupPolicy,
@@ -387,12 +389,10 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
} }
const imessageTo = (isGroup ? chatTarget : undefined) || `imessage:${sender}`; const imessageTo = (isGroup ? chatTarget : undefined) || `imessage:${sender}`;
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
BodyForAgent: combinedBody,
RawBody: bodyText, RawBody: bodyText,
CommandBody: bodyText, CommandBody: bodyText,
BodyForCommands: bodyText,
From: isGroup ? `group:${chatId}` : `imessage:${sender}`, From: isGroup ? `group:${chatId}` : `imessage:${sender}`,
To: imessageTo, To: imessageTo,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
@@ -416,7 +416,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
// Originating channel for reply routing. // Originating channel for reply routing.
OriginatingChannel: "imessage" as const, OriginatingChannel: "imessage" as const,
OriginatingTo: imessageTo, OriginatingTo: imessageTo,
}; });
ctxPayload.Body = formatInboundBodyWithSenderMeta({ ctx: ctxPayload, body: ctxPayload.Body });
if (!isGroup) { if (!isGroup) {
const sessionCfg = cfg.session; const sessionCfg = cfg.session;

View File

@@ -81,6 +81,8 @@ describe("applyMediaUnderstanding", () => {
expect(ctx.Body).toBe("[Audio]\nTranscript:\ntranscribed text"); expect(ctx.Body).toBe("[Audio]\nTranscript:\ntranscribed text");
expect(ctx.CommandBody).toBe("transcribed text"); expect(ctx.CommandBody).toBe("transcribed text");
expect(ctx.RawBody).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 () => { 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.Body).toBe("[Image]\nUser text:\nshow Dom\nDescription:\nimage description");
expect(ctx.CommandBody).toBe("show Dom"); expect(ctx.CommandBody).toBe("show Dom");
expect(ctx.RawBody).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 () => { it("uses shared media models list when capability config is missing", async () => {

View File

@@ -1,7 +1,10 @@
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { MsgContext } from "../auto-reply/templating.js"; import type { MsgContext } from "../auto-reply/templating.js";
import { applyTemplate } 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 { 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 { logVerbose, shouldLogVerbose } from "../globals.js";
import { runExec } from "../process/exec.js"; import { runExec } from "../process/exec.js";
import type { import type {
@@ -449,6 +452,7 @@ export async function applyMediaUnderstanding(params: {
ctx.RawBody = originalUserText; ctx.RawBody = originalUserText;
} }
ctx.MediaUnderstanding = [...(ctx.MediaUnderstanding ?? []), ...outputs]; ctx.MediaUnderstanding = [...(ctx.MediaUnderstanding ?? []), ...outputs];
finalizeInboundContext(ctx, { forceBodyForAgent: true, forceBodyForCommands: true });
} }
return { return {

View File

@@ -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");
});
});

View File

@@ -18,6 +18,8 @@ import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntries, clearHistoryEntries,
} from "../../auto-reply/reply/history.js"; } 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 { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import { resolveStorePath, updateLastRoute } from "../../config/sessions.js"; import { resolveStorePath, updateLastRoute } from "../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.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 signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`;
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
BodyForAgent: combinedBody,
RawBody: entry.bodyText, RawBody: entry.bodyText,
CommandBody: entry.bodyText, CommandBody: entry.bodyText,
BodyForCommands: entry.bodyText,
From: entry.isGroup From: entry.isGroup
? `group:${entry.groupId ?? "unknown"}` ? `group:${entry.groupId ?? "unknown"}`
: `signal:${entry.senderRecipient}`, : `signal:${entry.senderRecipient}`,
@@ -129,7 +129,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
CommandAuthorized: entry.commandAuthorized, CommandAuthorized: entry.commandAuthorized,
OriginatingChannel: "signal" as const, OriginatingChannel: "signal" as const,
OriginatingTo: signalTo, OriginatingTo: signalTo,
}; });
ctxPayload.Body = formatInboundBodyWithSenderMeta({ ctx: ctxPayload, body: ctxPayload.Body });
if (!entry.isGroup) { if (!entry.isGroup) {
const sessionCfg = deps.cfg.session; const sessionCfg = deps.cfg.session;

View File

@@ -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);
});
});

View File

@@ -6,6 +6,7 @@ import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
recordPendingHistoryEntry, recordPendingHistoryEntry,
} from "../../../auto-reply/reply/history.js"; } 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 { buildMentionRegexes, matchesMentionPatterns } from "../../../auto-reply/reply/mentions.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js"; import { enqueueSystemEvent } from "../../../infra/system-events.js";
@@ -404,12 +405,10 @@ export async function prepareSlackMessage(params: {
} }
} }
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
BodyForAgent: combinedBody,
RawBody: rawBody, RawBody: rawBody,
CommandBody: rawBody, CommandBody: rawBody,
BodyForCommands: rawBody,
From: slackFrom, From: slackFrom,
To: slackTo, To: slackTo,
SessionKey: sessionKey, SessionKey: sessionKey,
@@ -435,7 +434,7 @@ export async function prepareSlackMessage(params: {
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
OriginatingChannel: "slack" as const, OriginatingChannel: "slack" as const,
OriginatingTo: slackTo, OriginatingTo: slackTo,
} satisfies Record<string, unknown>; }) satisfies Record<string, unknown>;
const replyTarget = ctxPayload.To ?? undefined; const replyTarget = ctxPayload.To ?? undefined;
if (!replyTarget) return null; if (!replyTarget) return null;

View File

@@ -10,6 +10,7 @@ import {
} from "../../auto-reply/commands-registry.js"; } from "../../auto-reply/commands-registry.js";
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.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 { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js";
import { danger, logVerbose } from "../../globals.js"; import { danger, logVerbose } from "../../globals.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js";
@@ -336,11 +337,11 @@ export function registerSlackMonitorSlashCommands(params: {
const groupSystemPrompt = const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: prompt, Body: prompt,
BodyForAgent: prompt, RawBody: prompt,
CommandBody: prompt,
CommandArgs: commandArgs, CommandArgs: commandArgs,
BodyForCommands: prompt,
From: isDirectMessage From: isDirectMessage
? `slack:${command.user_id}` ? `slack:${command.user_id}`
: isRoom : isRoom
@@ -375,7 +376,7 @@ export function registerSlackMonitorSlashCommands(params: {
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
OriginatingChannel: "slack" as const, OriginatingChannel: "slack" as const,
OriginatingTo: `user:${command.user_id}`, OriginatingTo: `user:${command.user_id}`,
}; });
const { counts } = await dispatchReplyWithDispatcher({ const { counts } = await dispatchReplyWithDispatcher({
ctx: ctxPayload, ctx: ctxPayload,

View File

@@ -7,6 +7,7 @@ import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
recordPendingHistoryEntry, recordPendingHistoryEntry,
} from "../auto-reply/reply/history.js"; } 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 { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
import { formatLocationText, toLocationContext } from "../channels/location.js"; import { formatLocationText, toLocationContext } from "../channels/location.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
@@ -358,12 +359,10 @@ export const buildTelegramMessageContext = async ({
const groupSystemPrompt = const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const commandBody = normalizeCommandBody(rawBody, { botUsername }); const commandBody = normalizeCommandBody(rawBody, { botUsername });
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
BodyForAgent: combinedBody,
RawBody: rawBody, RawBody: rawBody,
CommandBody: commandBody, CommandBody: commandBody,
BodyForCommands: commandBody,
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
To: `telegram:${chatId}`, To: `telegram:${chatId}`,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
@@ -399,7 +398,7 @@ export const buildTelegramMessageContext = async ({
// Originating channel for reply routing. // Originating channel for reply routing.
OriginatingChannel: "telegram" as const, OriginatingChannel: "telegram" as const,
OriginatingTo: `telegram:${chatId}`, OriginatingTo: `telegram:${chatId}`,
}; });
if (replyTarget && shouldLogVerbose()) { if (replyTarget && shouldLogVerbose()) {
const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120); const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120);

View File

@@ -13,6 +13,7 @@ import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
import type { CommandArgs } from "../auto-reply/commands-registry.js"; import type { CommandArgs } from "../auto-reply/commands-registry.js";
import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.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 { danger, logVerbose } from "../globals.js";
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import { deliverReplies } from "./bot/delivery.js"; import { deliverReplies } from "./bot/delivery.js";
@@ -251,11 +252,11 @@ export const registerTelegramNativeCommands = ({
const conversationLabel = isGroup const conversationLabel = isGroup
? (msg.chat.title ? `${msg.chat.title} id:${chatId}` : `group:${chatId}`) ? (msg.chat.title ? `${msg.chat.title} id:${chatId}` : `group:${chatId}`)
: (buildSenderName(msg) ?? String(senderId || chatId)); : (buildSenderName(msg) ?? String(senderId || chatId));
const ctxPayload = { const ctxPayload = finalizeInboundContext({
Body: prompt, Body: prompt,
BodyForAgent: prompt, RawBody: prompt,
CommandBody: prompt,
CommandArgs: commandArgs, CommandArgs: commandArgs,
BodyForCommands: prompt,
From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`,
To: `slash:${senderId || chatId}`, To: `slash:${senderId || chatId}`,
ChatType: isGroup ? "group" : "direct", ChatType: isGroup ? "group" : "direct",
@@ -275,7 +276,7 @@ export const registerTelegramNativeCommands = ({
CommandTargetSessionKey: route.sessionKey, CommandTargetSessionKey: route.sessionKey,
MessageThreadId: resolvedThreadId, MessageThreadId: resolvedThreadId,
IsForum: isForum, IsForum: isForum,
}; });
const disableBlockStreaming = const disableBlockStreaming =
typeof telegramCfg.blockStreaming === "boolean" typeof telegramCfg.blockStreaming === "boolean"

View File

@@ -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);
});
});

View File

@@ -16,13 +16,13 @@ import {
import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js";
import type { getReplyFromConfig } from "../../../auto-reply/reply.js"; import type { getReplyFromConfig } from "../../../auto-reply/reply.js";
import type { ReplyPayload } from "../../../auto-reply/types.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 { toLocationContext } from "../../../channels/location.js";
import type { loadConfig } from "../../../config/config.js"; import type { loadConfig } from "../../../config/config.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import type { getChildLogger } from "../../../logging.js"; import type { getChildLogger } from "../../../logging.js";
import type { resolveAgentRoute } from "../../../routing/resolve-route.js"; import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { jidToE164, normalizeE164 } from "../../../utils.js"; import { jidToE164, normalizeE164 } from "../../../utils.js";
import { normalizeChatType } from "../../../channels/chat-type.js";
import { newConnectionId } from "../../reconnect.js"; import { newConnectionId } from "../../reconnect.js";
import { formatError } from "../../session.js"; import { formatError } from "../../session.js";
import { deliverWebReply } from "../deliver-reply.js"; import { deliverWebReply } from "../deliver-reply.js";
@@ -196,12 +196,10 @@ export async function processMessage(params: {
}; };
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: { ctx: finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
BodyForAgent: combinedBody,
RawBody: params.msg.body, RawBody: params.msg.body,
CommandBody: params.msg.body, CommandBody: params.msg.body,
BodyForCommands: params.msg.body,
From: params.msg.from, From: params.msg.from,
To: params.msg.to, To: params.msg.to,
SessionKey: params.route.sessionKey, SessionKey: params.route.sessionKey,
@@ -213,7 +211,7 @@ export async function processMessage(params: {
MediaPath: params.msg.mediaPath, MediaPath: params.msg.mediaPath,
MediaUrl: params.msg.mediaUrl, MediaUrl: params.msg.mediaUrl,
MediaType: params.msg.mediaType, 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, ConversationLabel: params.msg.chatType === "group" ? conversationId : params.msg.from,
GroupSubject: params.msg.groupSubject, GroupSubject: params.msg.groupSubject,
GroupMembers: formatGroupMembers({ GroupMembers: formatGroupMembers({
@@ -230,7 +228,7 @@ export async function processMessage(params: {
Surface: "whatsapp", Surface: "whatsapp",
OriginatingChannel: "whatsapp", OriginatingChannel: "whatsapp",
OriginatingTo: params.msg.from, OriginatingTo: params.msg.from,
}, }),
cfg: params.cfg, cfg: params.cfg,
replyResolver: params.replyResolver, replyResolver: params.replyResolver,
dispatcherOptions: { dispatcherOptions: {

View File

@@ -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);
});
}
});