From f7089cde54adb55d3d399dcb85b54af7e18a9a76 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 05:21:02 +0000 Subject: [PATCH] fix: unify inbound sender labels --- CHANGELOG.md | 1 + docs/concepts/messages.md | 4 + src/auto-reply/envelope.test.ts | 37 +++++++- src/auto-reply/envelope.ts | 24 +++++ .../reply/inbound-sender-meta.test.ts | 7 ++ src/auto-reply/reply/inbound-sender-meta.ts | 37 +++++--- src/channels/sender-label.ts | 43 +++++++++ ...ends-status-replies-responseprefix.test.ts | 91 +++++++++++++++++++ .../monitor/message-handler.process.ts | 21 ++++- ...essages-without-mention-by-default.test.ts | 31 +++++++ src/imessage/monitor/monitor-provider.ts | 14 +-- ...onitor.event-handler.sender-prefix.test.ts | 87 ++++++++++++++++++ src/signal/monitor/event-handler.ts | 12 ++- .../prepare.sender-prefix.test.ts | 80 ++++++++++++++++ src/slack/monitor/message-handler/prepare.ts | 15 ++- .../bot-message-context.sender-prefix.test.ts | 52 +++++++++++ src/telegram/bot-message-context.ts | 19 +++- .../auto-reply/monitor/message-line.test.ts | 34 +++++++ src/web/auto-reply/monitor/message-line.ts | 10 +- src/web/auto-reply/monitor/process-message.ts | 8 +- 20 files changed, 587 insertions(+), 40 deletions(-) create mode 100644 src/channels/sender-label.ts create mode 100644 src/signal/monitor.event-handler.sender-prefix.test.ts create mode 100644 src/slack/monitor/message-handler/prepare.sender-prefix.test.ts create mode 100644 src/telegram/bot-message-context.sender-prefix.test.ts create mode 100644 src/web/auto-reply/monitor/message-line.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b76bcd1..17cf60ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing. - Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea. - Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes. +- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059) - Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600). - Sessions: repair orphaned user turns before embedded prompts. - Channels: treat replies to the bot as implicit mentions across supported channels. diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index a6f225f53..e48197763 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -85,6 +85,10 @@ When a channel supplies history, it uses a shared wrapper: - `[Chat messages since your last reply - for context]` - `[Current message - respond to this]` +For **non-direct chats** (groups/channels/rooms), the **current message body** is prefixed with the +sender label (same style used for history entries). This keeps real-time and queued/history +messages consistent in the agent prompt. + History buffers are **pending-only**: they include group messages that did *not* trigger a run (for example, mention-gated messages) and **exclude** messages already in the session transcript. diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index e5b0f1455..7e8f39150 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatAgentEnvelope } from "./envelope.js"; +import { formatAgentEnvelope, formatInboundEnvelope } from "./envelope.js"; describe("formatAgentEnvelope", () => { it("includes channel, from, ip, host, and timestamp", () => { @@ -43,3 +43,38 @@ describe("formatAgentEnvelope", () => { expect(body).toBe("[Telegram] hi"); }); }); + +describe("formatInboundEnvelope", () => { + it("prefixes sender for non-direct chats", () => { + const body = formatInboundEnvelope({ + channel: "Discord", + from: "Guild #general", + body: "hi", + chatType: "channel", + senderLabel: "Alice", + }); + expect(body).toBe("[Discord Guild #general] Alice: hi"); + }); + + it("uses sender fields when senderLabel is missing", () => { + const body = formatInboundEnvelope({ + channel: "Signal", + from: "Signal Group id:123", + body: "ping", + chatType: "group", + sender: { name: "Bob", id: "42" }, + }); + expect(body).toBe("[Signal Signal Group id:123] Bob (42): ping"); + }); + + it("keeps direct messages unprefixed", () => { + const body = formatInboundEnvelope({ + channel: "iMessage", + from: "+1555", + body: "hello", + chatType: "direct", + senderLabel: "Alice", + }); + expect(body).toBe("[iMessage +1555] hello"); + }); +}); diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index bc1a8cdd5..000a1ecdf 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -1,3 +1,6 @@ +import { normalizeChatType } from "../channels/chat-type.js"; +import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js"; + export type AgentEnvelopeParams = { channel: string; from?: string; @@ -35,6 +38,27 @@ export function formatAgentEnvelope(params: AgentEnvelopeParams): string { return `${header} ${params.body}`; } +export function formatInboundEnvelope(params: { + channel: string; + from: string; + body: string; + timestamp?: number | Date; + chatType?: string; + senderLabel?: string; + sender?: SenderLabelParams; +}): string { + const chatType = normalizeChatType(params.chatType); + const isDirect = !chatType || chatType === "direct"; + const resolvedSender = params.senderLabel?.trim() || resolveSenderLabel(params.sender ?? {}); + const body = !isDirect && resolvedSender ? `${resolvedSender}: ${params.body}` : params.body; + return formatAgentEnvelope({ + channel: params.channel, + from: params.from, + timestamp: params.timestamp, + body, + }); +} + export function formatThreadStarterEnvelope(params: { channel: string; author?: string; diff --git a/src/auto-reply/reply/inbound-sender-meta.test.ts b/src/auto-reply/reply/inbound-sender-meta.test.ts index 6740621ed..ed8877496 100644 --- a/src/auto-reply/reply/inbound-sender-meta.test.ts +++ b/src/auto-reply/reply/inbound-sender-meta.test.ts @@ -41,4 +41,11 @@ describe("formatInboundBodyWithSenderMeta", () => { "[X] hi\n[from: Alice (A1)]", ); }); + + it("does not append when the body already includes a sender prefix", () => { + const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" }; + expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe( + "Alice (A1): hi", + ); + }); }); diff --git a/src/auto-reply/reply/inbound-sender-meta.ts b/src/auto-reply/reply/inbound-sender-meta.ts index 5dc0a7808..7895942ab 100644 --- a/src/auto-reply/reply/inbound-sender-meta.ts +++ b/src/auto-reply/reply/inbound-sender-meta.ts @@ -1,28 +1,43 @@ import type { MsgContext } from "../templating.js"; import { normalizeChatType } from "../../channels/chat-type.js"; +import { listSenderLabelCandidates, resolveSenderLabel } from "../../channels/sender-label.js"; export function formatInboundBodyWithSenderMeta(params: { body: string; ctx: MsgContext }): string { const body = params.body; if (!body.trim()) return body; const chatType = normalizeChatType(params.ctx.ChatType); if (!chatType || chatType === "direct") return body; - if (hasSenderMetaLine(body)) return body; + if (hasSenderMetaLine(body, params.ctx)) return body; - const senderLabel = formatSenderLabel(params.ctx); + const senderLabel = resolveSenderLabel({ + name: params.ctx.SenderName, + username: params.ctx.SenderUsername, + tag: params.ctx.SenderTag, + e164: params.ctx.SenderE164, + id: params.ctx.SenderId, + }); if (!senderLabel) return body; return `${body}\n[from: ${senderLabel}]`; } -function hasSenderMetaLine(body: string): boolean { - return /(^|\n)\[from:/i.test(body); +function hasSenderMetaLine(body: string, ctx: MsgContext): boolean { + if (/(^|\n)\[from:/i.test(body)) return true; + const candidates = listSenderLabelCandidates({ + name: ctx.SenderName, + username: ctx.SenderUsername, + tag: ctx.SenderTag, + e164: ctx.SenderE164, + id: ctx.SenderId, + }); + if (candidates.length === 0) return false; + return candidates.some((candidate) => { + const escaped = escapeRegExp(candidate); + const pattern = new RegExp(`(^|\\n)${escaped}:\\s`, "i"); + return pattern.test(body); + }); } -function formatSenderLabel(ctx: MsgContext): string | null { - const senderName = ctx.SenderName?.trim(); - const senderId = (ctx.SenderE164?.trim() || ctx.SenderId?.trim()) ?? ""; - if (senderName && senderId && senderName !== senderId) { - return `${senderName} (${senderId})`; - } - return senderName ?? (senderId || null); +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/channels/sender-label.ts b/src/channels/sender-label.ts new file mode 100644 index 000000000..2b230ab73 --- /dev/null +++ b/src/channels/sender-label.ts @@ -0,0 +1,43 @@ +export type SenderLabelParams = { + name?: string; + username?: string; + tag?: string; + e164?: string; + id?: string; +}; + +function normalize(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveSenderLabel(params: SenderLabelParams): string | null { + const name = normalize(params.name); + const username = normalize(params.username); + const tag = normalize(params.tag); + const e164 = normalize(params.e164); + const id = normalize(params.id); + + const display = name ?? username ?? tag ?? ""; + const idPart = e164 ?? id ?? ""; + if (display && idPart && display !== idPart) return `${display} (${idPart})`; + return display || idPart || null; +} + +export function listSenderLabelCandidates(params: SenderLabelParams): string[] { + const candidates = new Set(); + const name = normalize(params.name); + const username = normalize(params.username); + const tag = normalize(params.tag); + const e164 = normalize(params.e164); + const id = normalize(params.id); + + if (name) candidates.add(name); + if (username) candidates.add(username); + if (tag) candidates.add(tag); + if (e164) candidates.add(e164); + if (id) candidates.add(id); + const resolved = resolveSenderLabel(params); + if (resolved) candidates.add(resolved); + return Array.from(candidates); +} diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts index c055ddf02..f867e336d 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -369,6 +369,97 @@ describe("discord tool result dispatch", () => { expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1"); }); + it("prefixes group bodies with sender label", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + let capturedBody = ""; + dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { + capturedBody = ctx.Body ?? ""; + dispatcher.sendFinalReply({ text: "ok" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, + session: { store: "/tmp/clawdbot-sessions.json" }, + channels: { + discord: { + dm: { enabled: true, policy: "open" }, + guilds: { + "*": { + requireMention: false, + channels: { c1: { allow: true } }, + }, + }, + }, + }, + routing: { allowFrom: [] }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.channels.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { + "*": { requireMention: false, channels: { c1: { allow: true } } }, + }, + }); + + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, + name: "general", + parentId: "category-1", + }), + rest: { get: vi.fn() }, + } as unknown as Client; + + await handler( + { + message: { + id: "m-prefix", + content: "hello", + channelId: "c1", + timestamp: new Date("2026-01-17T00:00:00Z").toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada", discriminator: "1234" }, + }, + author: { id: "u1", bot: false, username: "Ada", discriminator: "1234" }, + member: { displayName: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + }, + client, + ); + + expect(capturedBody).toContain("Ada (Ada#1234): hello"); + }); + it("replies with pairing code and sender id when dmPolicy is pairing", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 3a88ece72..2bec07514 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -8,7 +8,10 @@ import { extractShortModelName, type ResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; -import { formatAgentEnvelope, formatThreadStarterEnvelope } from "../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + formatThreadStarterEnvelope, +} from "../../auto-reply/envelope.js"; import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; import { buildPendingHistoryContextFromMap, @@ -118,6 +121,12 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) channelName: channelName ?? message.channelId, channelId: message.channelId, }); + const senderTag = formatDiscordUserTag(author); + const senderDisplay = data.member?.nickname ?? author.globalName ?? author.username; + const senderLabel = + senderDisplay && senderTag && senderDisplay !== senderTag + ? `${senderDisplay} (${senderTag})` + : senderDisplay ?? senderTag ?? author.id; const groupRoom = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined; const groupSubject = isDirectMessage ? undefined : groupRoom; const channelDescription = channelInfo?.topic?.trim(); @@ -127,11 +136,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ].filter((entry): entry is string => Boolean(entry)); const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; - let combinedBody = formatAgentEnvelope({ + let combinedBody = formatInboundEnvelope({ channel: "Discord", from: fromLabel, timestamp: resolveTimestampMs(message.timestamp), body: text, + chatType: isDirectMessage ? "direct" : "channel", + senderLabel, }); const shouldIncludeChannelHistory = !isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel); @@ -142,11 +153,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - formatAgentEnvelope({ + formatInboundEnvelope({ channel: "Discord", from: fromLabel, timestamp: entry.timestamp, - body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`, + body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`, + chatType: "channel", + senderLabel: entry.sender, }), }); } 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 4eef2218d..1a385ff5d 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 @@ -461,4 +461,35 @@ describe("monitorIMessageProvider", () => { expect(replyMock).not.toHaveBeenCalled(); }); + + it("prefixes group message bodies with sender", async () => { + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 11, + chat_id: 99, + chat_name: "Test Group", + sender: "+15550001111", + is_from_me: false, + text: "@clawd hi", + is_group: true, + created_at: "2026-01-17T00:00:00Z", + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).toHaveBeenCalled(); + const ctx = replyMock.mock.calls[0]?.[0]; + const body = ctx?.Body ?? ""; + expect(body).toContain("Test Group id:99"); + expect(body).toContain("+15550001111: @clawd hi"); + }); }); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index e26b34020..f1ea083c1 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -11,7 +11,7 @@ import { } from "../../auto-reply/reply/response-prefix-template.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { formatAgentEnvelope } from "../../auto-reply/envelope.js"; +import { formatInboundEnvelope } from "../../auto-reply/envelope.js"; import { createInboundDebouncer, resolveInboundDebounceMs, @@ -363,11 +363,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const fromLabel = isGroup ? `${message.chat_name || "iMessage Group"} id:${chatId ?? "unknown"}` : `${senderNormalized} id:${sender}`; - const body = formatAgentEnvelope({ + const body = formatInboundEnvelope({ channel: "iMessage", from: fromLabel, timestamp: createdAt, body: bodyText, + chatType: isGroup ? "group" : "direct", + sender: { name: senderNormalized, id: sender }, }); let combinedBody = body; if (isGroup && historyKey && historyLimit > 0) { @@ -377,13 +379,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - formatAgentEnvelope({ + formatInboundEnvelope({ channel: "iMessage", from: fromLabel, timestamp: entry.timestamp, - body: `${entry.sender}: ${entry.body}${ - entry.messageId ? ` [id:${entry.messageId}]` : "" - }`, + body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, + chatType: "group", + senderLabel: entry.sender, }), }); } diff --git a/src/signal/monitor.event-handler.sender-prefix.test.ts b/src/signal/monitor.event-handler.sender-prefix.test.ts new file mode 100644 index 000000000..1e1f301b9 --- /dev/null +++ b/src/signal/monitor.event-handler.sender-prefix.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + + +const dispatchMock = vi.fn(); +const readAllowFromMock = vi.fn(); + +vi.mock("../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => readAllowFromMock(...args), + upsertChannelPairingRequest: vi.fn(), +})); + + +describe("signal event handler sender prefix", () => { + beforeEach(() => { + dispatchMock.mockReset().mockImplementation(async ({ dispatcher, ctx }) => { + dispatcher.sendFinalReply({ text: "ok" }); + return { queuedFinal: true, counts: { final: 1 }, ctx }; + }); + readAllowFromMock.mockReset().mockResolvedValue([]); + }); + + it("prefixes group bodies with sender label", async () => { + let capturedBody = ""; + const dispatchModule = await import("../auto-reply/reply/dispatch-from-config.js"); + vi.spyOn(dispatchModule, "dispatchReplyFromConfig").mockImplementation( + async (...args: unknown[]) => dispatchMock(...args), + ); + dispatchMock.mockImplementationOnce(async ({ dispatcher, ctx }) => { + capturedBody = ctx.Body ?? ""; + dispatcher.sendFinalReply({ text: "ok" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + + const { createSignalEventHandler } = await import("./monitor/event-handler.js"); + const handler = createSignalEventHandler({ + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { signal: {} }, + } as never, + baseUrl: "http://localhost", + account: "+15550009999", + accountId: "default", + blockStreaming: false, + historyLimit: 0, + groupHistories: new Map(), + textLimit: 4000, + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + groupPolicy: "open", + reactionMode: "off", + reactionAllowlist: [], + mediaMaxBytes: 1000, + ignoreAttachments: true, + fetchAttachment: async () => null, + deliverReplies: async () => undefined, + resolveSignalReactionTargets: () => [], + isSignalReactionMessage: () => false, + shouldEmitSignalReactionNotification: () => false, + buildSignalReactionSystemEventText: () => "", + }); + + const payload = { + envelope: { + sourceNumber: "+15550002222", + sourceName: "Alice", + timestamp: 1700000000000, + dataMessage: { + message: "hello", + groupInfo: { groupId: "group-1", groupName: "Test Group" }, + }, + }, + }; + + await handler({ event: "receive", data: JSON.stringify(payload) }); + + expect(dispatchMock).toHaveBeenCalled(); + expect(capturedBody).toContain("Alice (+15550002222): hello"); + }); +}); diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index c70934da2..d86d76889 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -8,7 +8,7 @@ import { type ResponsePrefixContext, } from "../../auto-reply/reply/response-prefix-template.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js"; -import { formatAgentEnvelope } from "../../auto-reply/envelope.js"; +import { formatInboundEnvelope } from "../../auto-reply/envelope.js"; import { createInboundDebouncer, resolveInboundDebounceMs, @@ -68,11 +68,13 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const fromLabel = entry.isGroup ? `${entry.groupName ?? "Signal Group"} id:${entry.groupId}` : `${entry.senderName} id:${entry.senderDisplay}`; - const body = formatAgentEnvelope({ + const body = formatInboundEnvelope({ channel: "Signal", from: fromLabel, timestamp: entry.timestamp ?? undefined, body: entry.bodyText, + chatType: entry.isGroup ? "group" : "direct", + sender: { name: entry.senderName, id: entry.senderDisplay }, }); let combinedBody = body; const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined; @@ -83,13 +85,15 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { limit: deps.historyLimit, currentMessage: combinedBody, formatEntry: (historyEntry) => - formatAgentEnvelope({ + formatInboundEnvelope({ channel: "Signal", from: fromLabel, timestamp: historyEntry.timestamp, - body: `${historyEntry.sender}: ${historyEntry.body}${ + body: `${historyEntry.body}${ historyEntry.messageId ? ` [id:${historyEntry.messageId}]` : "" }`, + chatType: "group", + senderLabel: historyEntry.sender, }), }); } diff --git a/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts b/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts new file mode 100644 index 000000000..ef6edb1fc --- /dev/null +++ b/src/slack/monitor/message-handler/prepare.sender-prefix.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SlackMonitorContext } from "../context.js"; +import { prepareSlackMessage } from "./prepare.js"; + +describe("prepareSlackMessage sender prefix", () => { + it("prefixes channel bodies with sender label", async () => { + const ctx = { + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { slack: {} }, + }, + accountId: "default", + botToken: "xoxb", + app: { client: {} }, + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "BOT", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + channelHistories: new Map(), + sessionScope: "per-sender", + mainKey: "agent:main:main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupDmEnabled: false, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "off", + threadHistoryScope: "channel", + threadInheritParent: false, + slashCommand: { command: "/clawd", enabled: true }, + textLimit: 2000, + ackReactionScope: "off", + mediaMaxBytes: 1000, + removeAckAfterReply: false, + logger: { info: vi.fn() }, + markMessageSeen: () => false, + shouldDropMismatchedSlackEvent: () => false, + resolveSlackSystemEventSessionKey: () => "agent:main:slack:channel:C1", + isChannelAllowed: () => true, + resolveChannelName: async () => ({ + name: "general", + type: "channel", + }), + resolveUserName: async () => ({ name: "Alice" }), + setSlackThreadStatus: async () => undefined, + } satisfies SlackMonitorContext; + + const result = await prepareSlackMessage({ + ctx, + account: { accountId: "default", config: {} } as never, + message: { + type: "message", + channel: "C1", + channel_type: "channel", + text: "<@BOT> hello", + user: "U1", + ts: "1700000000.0001", + event_ts: "1700000000.0001", + } as never, + opts: { source: "message", wasMentioned: true }, + }); + + expect(result).not.toBeNull(); + const body = result?.ctxPayload.Body ?? ""; + expect(body).toContain("Alice (U1): <@BOT> hello"); + }); +}); diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index a84f19402..c79792fb2 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -1,7 +1,10 @@ import { resolveAckReaction } from "../../../agents/identity.js"; import { hasControlCommand } from "../../../auto-reply/command-detection.js"; import { shouldHandleTextCommands } from "../../../auto-reply/commands-registry.js"; -import { formatAgentEnvelope, formatThreadStarterEnvelope } from "../../../auto-reply/envelope.js"; +import { + formatInboundEnvelope, + formatThreadStarterEnvelope, +} from "../../../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntry, @@ -340,11 +343,13 @@ export async function prepareSlackMessage(params: { From: slackFrom, }) ?? (isDirectMessage ? senderName : roomLabel); const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; - const body = formatAgentEnvelope({ + const body = formatInboundEnvelope({ channel: "Slack", from: envelopeFrom, timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, body: textWithId, + chatType: isDirectMessage ? "direct" : "channel", + sender: { name: senderName, id: senderId }, }); let combinedBody = body; @@ -355,13 +360,15 @@ export async function prepareSlackMessage(params: { limit: ctx.historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - formatAgentEnvelope({ + formatInboundEnvelope({ channel: "Slack", from: roomLabel, timestamp: entry.timestamp, - body: `${entry.sender}: ${entry.body}${ + body: `${entry.body}${ entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : "" }`, + chatType: "channel", + senderLabel: entry.sender, }), }); } diff --git a/src/telegram/bot-message-context.sender-prefix.test.ts b/src/telegram/bot-message-context.sender-prefix.test.ts new file mode 100644 index 000000000..12d7b09e5 --- /dev/null +++ b/src/telegram/bot-message-context.sender-prefix.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +import { buildTelegramMessageContext } from "./bot-message-context.js"; + +describe("buildTelegramMessageContext sender prefix", () => { + it("prefixes group bodies with sender label", async () => { + const ctx = await buildTelegramMessageContext({ + primaryCtx: { + message: { + message_id: 1, + chat: { id: -99, type: "supergroup", title: "Dev Chat" }, + date: 1700000000, + text: "hello", + from: { id: 42, first_name: "Alice" }, + }, + me: { id: 7, username: "bot" }, + } as never, + allMedia: [], + storeAllowFrom: [], + options: {}, + bot: { + api: { + sendChatAction: vi.fn(), + setMessageReaction: vi.fn(), + }, + } as never, + cfg: { + agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } }, + channels: { telegram: {} }, + messages: { groupChat: { mentionPatterns: [] } }, + } as never, + account: { accountId: "default" } as never, + historyLimit: 0, + groupHistories: new Map(), + dmPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ackReactionScope: "off", + logger: { info: vi.fn() }, + resolveGroupActivation: () => undefined, + resolveGroupRequireMention: () => false, + resolveTelegramGroupConfig: () => ({ + groupConfig: { requireMention: false }, + topicConfig: undefined, + }), + }); + + expect(ctx).not.toBeNull(); + const body = ctx?.ctxPayload?.Body ?? ""; + expect(body).toContain("Alice (42): hello"); + }); +}); diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 5053c2e2f..90a92e6c9 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -2,7 +2,7 @@ import { resolveAckReaction } from "../agents/identity.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { normalizeCommandBody } from "../auto-reply/commands-registry.js"; -import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { formatInboundEnvelope } from "../auto-reply/envelope.js"; import { buildPendingHistoryContextFromMap, recordPendingHistoryEntry, @@ -325,14 +325,21 @@ export const buildTelegramMessageContext = async ({ }]\n${replyTarget.body}\n[/Replying]` : ""; const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined; + const senderName = buildSenderName(msg); const conversationLabel = isGroup ? (groupLabel ?? `group:${chatId}`) : buildSenderLabel(msg, senderId || chatId); - const body = formatAgentEnvelope({ + const body = formatInboundEnvelope({ channel: "Telegram", from: conversationLabel, timestamp: msg.date ? msg.date * 1000 : undefined, body: `${bodyText}${replySuffix}`, + chatType: isGroup ? "group" : "direct", + sender: { + name: senderName, + username: senderUsername || undefined, + id: senderId || undefined, + }, }); let combinedBody = body; if (isGroup && historyKey && historyLimit > 0) { @@ -342,11 +349,13 @@ export const buildTelegramMessageContext = async ({ limit: historyLimit, currentMessage: combinedBody, formatEntry: (entry) => - formatAgentEnvelope({ + formatInboundEnvelope({ channel: "Telegram", from: groupLabel ?? `group:${chatId}`, timestamp: entry.timestamp, - body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`, + body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`, + chatType: "group", + senderLabel: entry.sender, }), }); } @@ -371,7 +380,7 @@ export const buildTelegramMessageContext = async ({ ConversationLabel: conversationLabel, GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, - SenderName: buildSenderName(msg), + SenderName: senderName, SenderId: senderId || undefined, SenderUsername: senderUsername || undefined, Provider: "telegram", diff --git a/src/web/auto-reply/monitor/message-line.test.ts b/src/web/auto-reply/monitor/message-line.test.ts new file mode 100644 index 000000000..9e0bf9ede --- /dev/null +++ b/src/web/auto-reply/monitor/message-line.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { buildInboundLine } from "./message-line.js"; + +describe("buildInboundLine", () => { + it("prefixes group messages with sender", () => { + const line = buildInboundLine({ + cfg: { + agents: { defaults: { workspace: "/tmp/clawd" } }, + channels: { whatsapp: { messagePrefix: "" } }, + } as never, + agentId: "main", + msg: { + from: "123@g.us", + conversationId: "123@g.us", + to: "+15550009999", + accountId: "default", + body: "ping", + timestamp: 1700000000000, + chatType: "group", + chatId: "123@g.us", + senderJid: "111@s.whatsapp.net", + senderE164: "+15550001111", + senderName: "Bob", + sendComposing: async () => undefined, + reply: async () => undefined, + sendMedia: async () => undefined, + } as never, + }); + + expect(line).toContain("Bob (+15550001111):"); + expect(line).toContain("ping"); + }); +}); diff --git a/src/web/auto-reply/monitor/message-line.ts b/src/web/auto-reply/monitor/message-line.ts index 8fcedebc1..336dd2abc 100644 --- a/src/web/auto-reply/monitor/message-line.ts +++ b/src/web/auto-reply/monitor/message-line.ts @@ -1,5 +1,5 @@ import { resolveMessagePrefix } from "../../../agents/identity.js"; -import { formatAgentEnvelope } from "../../../auto-reply/envelope.js"; +import { formatInboundEnvelope } from "../../../auto-reply/envelope.js"; import type { loadConfig } from "../../../config/config.js"; import type { WebInboundMsg } from "../types.js"; @@ -26,10 +26,16 @@ export function buildInboundLine(params: { const baseLine = `${prefixStr}${msg.body}${replyContext ? `\n\n${replyContext}` : ""}`; // Wrap with standardized envelope for the agent. - return formatAgentEnvelope({ + return formatInboundEnvelope({ channel: "WhatsApp", from: msg.chatType === "group" ? msg.from : msg.from?.replace(/^whatsapp:/, ""), timestamp: msg.timestamp, body: baseLine, + chatType: msg.chatType, + sender: { + name: msg.senderName, + e164: msg.senderE164, + id: msg.senderJid, + }, }); } diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index 314290eee..731c8e57b 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -8,7 +8,7 @@ import { type ResponsePrefixContext, } from "../../../auto-reply/reply/response-prefix-template.js"; import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js"; -import { formatAgentEnvelope } from "../../../auto-reply/envelope.js"; +import { formatInboundEnvelope } from "../../../auto-reply/envelope.js"; import { buildHistoryContextFromEntries, type HistoryEntry, @@ -95,11 +95,13 @@ export async function processMessage(params: { const bodyWithId = entry.messageId ? `${entry.body}\n[message_id: ${entry.messageId}]` : entry.body; - return formatAgentEnvelope({ + return formatInboundEnvelope({ channel: "WhatsApp", from: conversationId, timestamp: entry.timestamp, - body: `${entry.sender}: ${bodyWithId}`, + body: bodyWithId, + chatType: "group", + senderLabel: entry.sender, }); }, });