From b20339a2329639a23fca6cc0d317619f21064bbb Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:17:22 -0800 Subject: [PATCH] fix(signal): canonicalize message targets in tool and inbound flows --- src/auto-reply/reply/reply-plumbing.test.ts | 33 ++++++++++++ src/channels/dock.ts | 33 +++++++++--- src/channels/plugins/normalize/signal.ts | 51 +++++++++++-------- src/channels/plugins/plugins-channel.test.ts | 5 ++ .../event-handler.inbound-contract.test.ts | 37 ++++++++++++++ src/signal/monitor/event-handler.ts | 6 ++- 6 files changed, 135 insertions(+), 30 deletions(-) diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts index 0a66475b3f..881147f164 100644 --- a/src/auto-reply/reply/reply-plumbing.test.ts +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -62,6 +62,39 @@ describe("buildThreadingToolContext", () => { expect(result.currentChannelId).toBe("chat:99"); }); + it("normalizes signal direct targets for tool context", () => { + const sessionCtx = { + Provider: "signal", + ChatType: "direct", + From: "signal:+15550001", + To: "signal:+15550002", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("+15550001"); + }); + + it("preserves signal group ids for tool context", () => { + const sessionCtx = { + Provider: "signal", + ChatType: "group", + To: "signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); + }); + it("uses the sender handle for iMessage direct chats", () => { const sessionCtx = { Provider: "imessage", diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 5473cf7cda..24a14aaee8 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -28,6 +28,7 @@ import { resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, } from "./plugins/group-mentions.js"; +import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js"; import type { ChannelCapabilities, ChannelCommandAdapter, @@ -97,16 +98,32 @@ const formatDiscordAllowFrom = (allowFrom: Array) => ) .filter(Boolean); -function buildDirectOrGroupThreadToolContext(params: { +function resolveDirectOrGroupChannelId(context: ChannelThreadingContext): string | undefined { + const isDirect = context.ChatType?.toLowerCase() === "direct"; + return (isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined; +} + +function buildSignalThreadToolContext(params: { context: ChannelThreadingContext; hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"]; }): ChannelThreadingToolContext { - const isDirect = params.context.ChatType?.toLowerCase() === "direct"; - const channelId = - (isDirect ? (params.context.From ?? params.context.To) : params.context.To)?.trim() || - undefined; + const currentChannelIdRaw = resolveDirectOrGroupChannelId(params.context); + const currentChannelId = currentChannelIdRaw + ? (normalizeSignalMessagingTarget(currentChannelIdRaw) ?? currentChannelIdRaw.trim()) + : undefined; return { - currentChannelId: channelId, + currentChannelId, + currentThreadTs: params.context.ReplyToId, + hasRepliedRef: params.hasRepliedRef, + }; +} + +function buildIMessageThreadToolContext(params: { + context: ChannelThreadingContext; + hasRepliedRef: ChannelThreadingToolContext["hasRepliedRef"]; +}): ChannelThreadingToolContext { + return { + currentChannelId: resolveDirectOrGroupChannelId(params.context), currentThreadTs: params.context.ReplyToId, hasRepliedRef: params.hasRepliedRef, }; @@ -437,7 +454,7 @@ const DOCKS: Record = { }, threading: { buildToolContext: ({ context, hasRepliedRef }) => - buildDirectOrGroupThreadToolContext({ context, hasRepliedRef }), + buildSignalThreadToolContext({ context, hasRepliedRef }), }, }, imessage: { @@ -462,7 +479,7 @@ const DOCKS: Record = { }, threading: { buildToolContext: ({ context, hasRepliedRef }) => - buildDirectOrGroupThreadToolContext({ context, hasRepliedRef }), + buildIMessageThreadToolContext({ context, hasRepliedRef }), }, }, }; diff --git a/src/channels/plugins/normalize/signal.ts b/src/channels/plugins/normalize/signal.ts index c7523aa962..c4b9e4090c 100644 --- a/src/channels/plugins/normalize/signal.ts +++ b/src/channels/plugins/normalize/signal.ts @@ -35,27 +35,36 @@ export function normalizeSignalMessagingTarget(raw: string): string | undefined const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i; -export function looksLikeSignalTargetId(raw: string): boolean { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) { - return true; - } - if (/^(signal:)?uuid:/i.test(trimmed)) { - const stripped = trimmed - .replace(/^signal:/i, "") - .replace(/^uuid:/i, "") - .trim(); - if (!stripped) { - return false; +export function looksLikeSignalTargetId(raw: string, normalized?: string): boolean { + const candidates = [raw, normalized ?? ""].map((value) => value.trim()).filter(Boolean); + + for (const candidate of candidates) { + if (/^(signal:)?(group:|username:|u:)/i.test(candidate)) { + return true; + } + if (/^(signal:)?uuid:/i.test(candidate)) { + const stripped = candidate + .replace(/^signal:/i, "") + .replace(/^uuid:/i, "") + .trim(); + if (!stripped) { + continue; + } + if (UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped)) { + return true; + } + continue; + } + + const withoutSignalPrefix = candidate.replace(/^signal:/i, "").trim(); + // Accept UUIDs (used by signal-cli for reactions) + if (UUID_PATTERN.test(withoutSignalPrefix) || UUID_COMPACT_PATTERN.test(withoutSignalPrefix)) { + return true; + } + if (/^\+?\d{3,}$/.test(withoutSignalPrefix)) { + return true; } - return UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped); } - // Accept UUIDs (used by signal-cli for reactions) - if (UUID_PATTERN.test(trimmed) || UUID_COMPACT_PATTERN.test(trimmed)) { - return true; - } - return /^\+?\d{3,}$/.test(trimmed); + + return false; } diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts index 91277158d2..d3f72f290f 100644 --- a/src/channels/plugins/plugins-channel.test.ts +++ b/src/channels/plugins/plugins-channel.test.ts @@ -42,6 +42,11 @@ describe("signal target normalization", () => { expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); }); + it("accepts signal-prefixed E.164 targets for detection", () => { + expect(looksLikeSignalTargetId("signal:+15551234567")).toBe(true); + expect(looksLikeSignalTargetId("signal:15551234567")).toBe(true); + }); + it("accepts compact UUIDs for target detection", () => { expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); diff --git a/src/signal/monitor/event-handler.inbound-contract.test.ts b/src/signal/monitor/event-handler.inbound-contract.test.ts index 51df113f63..49b47706a5 100644 --- a/src/signal/monitor/event-handler.inbound-contract.test.ts +++ b/src/signal/monitor/event-handler.inbound-contract.test.ts @@ -51,4 +51,41 @@ describe("signal createSignalEventHandler inbound contract", () => { expect(String(contextWithBody.Body ?? "")).toMatch(/Alice.*:/); expect(String(contextWithBody.Body ?? "")).not.toContain("[from:"); }); + + it("normalizes direct chat To/OriginatingTo targets to canonical Signal ids", async () => { + capturedCtx = undefined; + + const handler = createSignalEventHandler( + createBaseSignalEventHandlerDeps({ + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: { inbound: { debounceMs: 0 } } } as any, + historyLimit: 0, + }), + ); + + await handler({ + event: "receive", + data: JSON.stringify({ + envelope: { + sourceNumber: "+15550002222", + sourceName: "Bob", + timestamp: 1700000000001, + dataMessage: { + message: "hello", + attachments: [], + }, + }, + }), + }); + + expect(capturedCtx).toBeTruthy(); + const context = capturedCtx as unknown as { + ChatType?: string; + To?: string; + OriginatingTo?: string; + }; + expect(context.ChatType).toBe("direct"); + expect(context.To).toBe("+15550002222"); + expect(context.OriginatingTo).toBe("+15550002222"); + }); }); diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 73c9edc843..71c8121852 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -21,6 +21,7 @@ import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-di import { resolveControlCommandGate } from "../../channels/command-gating.js"; import { logInboundDrop, logTypingFailure } from "../../channels/logging.js"; import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js"; +import { normalizeSignalMessagingTarget } from "../../channels/plugins/normalize/signal.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; import { recordInboundSession } from "../../channels/session.js"; import { createTypingCallbacks } from "../../channels/typing.js"; @@ -126,7 +127,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }), }); } - const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`; + const signalToRaw = entry.isGroup + ? `group:${entry.groupId}` + : `signal:${entry.senderRecipient}`; + const signalTo = normalizeSignalMessagingTarget(signalToRaw) ?? signalToRaw; const inboundHistory = entry.isGroup && historyKey && deps.historyLimit > 0 ? (deps.groupHistories.get(historyKey) ?? []).map((historyEntry) => ({