diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 2dac96ca1..0605a3628 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -184,4 +184,77 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalledTimes(1); }); + + it("appends sender meta for non-direct chats when missing", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx: MsgContext = { + Provider: "imessage", + ChatType: "group", + Body: "[iMessage group:1] hello", + SenderName: "+15555550123", + SenderId: "+15555550123", + }; + + const replyResolver = vi.fn(async (resolvedCtx: MsgContext) => { + expect(resolvedCtx.Body).toContain("\n[from: +15555550123]"); + return { text: "ok" } satisfies ReplyPayload; + }); + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(replyResolver).toHaveBeenCalledTimes(1); + }); + + it("does not append sender meta when Body already includes a from line", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx: MsgContext = { + Provider: "whatsapp", + ChatType: "group", + Body: "[WhatsApp group:1] hello\\n[from: Bob (+222)]", + SenderName: "Bob", + SenderId: "+222", + }; + + const replyResolver = vi.fn(async (resolvedCtx: MsgContext) => { + expect(resolvedCtx.Body.match(/\\n\[from:/g)?.length ?? 0).toBe(1); + return { text: "ok" } satisfies ReplyPayload; + }); + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(replyResolver).toHaveBeenCalledTimes(1); + }); + + it("does not append sender meta for other providers (scope is signal/imessage only)", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx: MsgContext = { + Provider: "slack", + OriginatingChannel: "slack", + ChatType: "group", + Body: "[Slack #room 2026-01-01T00:00Z] hi", + SenderName: "Bob", + SenderId: "U123", + }; + + const replyResolver = vi.fn(async (resolvedCtx: MsgContext) => { + expect(resolvedCtx.Body).not.toContain("[from:"); + return { text: "ok" } satisfies ReplyPayload; + }); + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + expect(replyResolver).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index c7a766fed..59d99b2cb 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -22,6 +22,8 @@ export async function dispatchReplyFromConfig(params: { }): Promise { const { ctx, cfg, dispatcher } = params; + maybeAppendSenderMeta(ctx); + if (shouldSkipDuplicateInbound(ctx)) { return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; } @@ -160,3 +162,40 @@ export async function dispatchReplyFromConfig(params: { counts.final += routedFinalCount; return { queuedFinal, counts }; } + +function maybeAppendSenderMeta(ctx: MsgContext): void { + if (!ctx.Body?.trim()) return; + if (ctx.ChatType !== "group") return; + if (!shouldInjectSenderMeta(ctx)) return; + if (hasSenderMetaLine(ctx.Body)) return; + + const senderLabel = formatSenderLabel(ctx); + if (!senderLabel) return; + + const lineBreak = resolveBodyLineBreak(ctx.Body); + ctx.Body = `${ctx.Body}${lineBreak}[from: ${senderLabel}]`; +} + +function shouldInjectSenderMeta(ctx: MsgContext): boolean { + const origin = (ctx.OriginatingChannel ?? ctx.Provider ?? "").toLowerCase(); + return origin === "imessage" || origin === "signal"; +} + +function resolveBodyLineBreak(body: string): string { + if (body.includes("\n")) return "\n"; + if (body.includes("\\n")) return "\\n"; + return "\n"; +} + +function hasSenderMetaLine(body: string): boolean { + return /(^|\n|\\n)\[from:/i.test(body); +} + +function formatSenderLabel(ctx: MsgContext): string | null { + const senderName = ctx.SenderName?.trim(); + const senderId = ctx.SenderId?.trim(); + if (senderName && senderId && senderName !== senderId) { + return `${senderName} (${senderId})`; + } + return senderName ?? senderId ?? null; +} diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 48df4957f..67c376b48 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -164,6 +164,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const senderRaw = message.sender ?? ""; const sender = senderRaw.trim(); if (!sender) return; + const senderNormalized = normalizeIMessageHandle(sender); if (message.is_from_me) return; const chatId = message.chat_id ?? undefined; @@ -346,7 +347,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P historyKey, limit: historyLimit, entry: { - sender: normalizeIMessageHandle(sender), + sender: senderNormalized, body: bodyText, timestamp: createdAt, messageId: message.id ? String(message.id) : undefined, @@ -359,7 +360,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const chatTarget = formatIMessageChatTarget(chatId); const fromLabel = isGroup ? `${message.chat_name || "iMessage Group"} id:${chatId ?? "unknown"}` - : `${normalizeIMessageHandle(sender)} id:${sender}`; + : `${senderNormalized} id:${sender}`; const body = formatAgentEnvelope({ channel: "iMessage", from: fromLabel, @@ -397,7 +398,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (message.chat_name ?? undefined) : undefined, GroupMembers: isGroup ? (message.participants ?? []).filter(Boolean).join(", ") : undefined, - SenderName: sender, + SenderName: senderNormalized, SenderId: sender, Provider: "imessage", Surface: "imessage",