From cfec19df53c68f1c89d7166c2cd05d1ca72ab530 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 12 Feb 2026 12:48:22 -0800 Subject: [PATCH] Signal: normalize mention placeholders --- .../event-handler.mention-gating.test.ts | 68 +++++++++++++++++++ src/signal/monitor/event-handler.ts | 20 ++---- src/signal/monitor/mentions.test.ts | 34 ++++++++++ src/signal/monitor/mentions.ts | 42 ++++++++++++ 4 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 src/signal/monitor/mentions.test.ts create mode 100644 src/signal/monitor/mentions.ts diff --git a/src/signal/monitor/event-handler.mention-gating.test.ts b/src/signal/monitor/event-handler.mention-gating.test.ts index 9bdf0c59be..f5c95cd802 100644 --- a/src/signal/monitor/event-handler.mention-gating.test.ts +++ b/src/signal/monitor/event-handler.mention-gating.test.ts @@ -53,6 +53,14 @@ type GroupEventOpts = { message?: string; attachments?: unknown[]; quoteText?: string; + mentions?: + | Array<{ + uuid?: string; + number?: string; + start?: number; + length?: number; + }> + | null; }; function makeGroupEvent(opts: GroupEventOpts) { @@ -67,6 +75,7 @@ function makeGroupEvent(opts: GroupEventOpts) { message: opts.message ?? "", attachments: opts.attachments ?? [], quote: opts.quoteText ? { text: opts.quoteText } : undefined, + mentions: opts.mentions ?? undefined, groupInfo: { groupId: "g1", groupName: "Test Group" }, }, }, @@ -203,4 +212,63 @@ describe("signal mention gating", () => { await handler(makeGroupEvent({ message: "/help" })); expect(capturedCtx).toBeTruthy(); }); + + it("hydrates mention placeholders before trimming so offsets stay aligned", async () => { + capturedCtx = undefined; + const handler = createSignalEventHandler( + createBaseDeps({ + cfg: { + messages: { inbound: { debounceMs: 0 }, groupChat: { mentionPatterns: ["@bot"] } }, + channels: { signal: { groups: { "*": { requireMention: false } } } }, + }, + }), + ); + + const placeholder = "\uFFFC"; + const message = `\n${placeholder} hi ${placeholder}`; + const firstStart = message.indexOf(placeholder); + const secondStart = message.indexOf(placeholder, firstStart + 1); + + await handler( + makeGroupEvent({ + message, + mentions: [ + { uuid: "123e4567", start: firstStart, length: placeholder.length }, + { number: "+15550002222", start: secondStart, length: placeholder.length }, + ], + }), + ); + + expect(capturedCtx).toBeTruthy(); + const body = String(capturedCtx?.Body ?? ""); + expect(body).toContain("@123e4567 hi @+15550002222"); + expect(body).not.toContain(placeholder); + }); + + it("counts mention metadata replacements toward requireMention gating", async () => { + capturedCtx = undefined; + const handler = createSignalEventHandler( + createBaseDeps({ + cfg: { + messages: { inbound: { debounceMs: 0 }, groupChat: { mentionPatterns: ["@123e4567"] } }, + channels: { signal: { groups: { "*": { requireMention: true } } } }, + }, + }), + ); + + const placeholder = "\uFFFC"; + const message = ` ${placeholder} ping`; + const start = message.indexOf(placeholder); + + await handler( + makeGroupEvent({ + message, + mentions: [{ uuid: "123e4567", start, length: placeholder.length }], + }), + ); + + expect(capturedCtx).toBeTruthy(); + expect(String(capturedCtx?.Body ?? "")).toContain("@123e4567"); + expect(capturedCtx?.WasMentioned).toBe(true); + }); }); diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts index 57af517b16..ea31b0f6a9 100644 --- a/src/signal/monitor/event-handler.ts +++ b/src/signal/monitor/event-handler.ts @@ -47,7 +47,7 @@ import { resolveSignalSender, } from "../identity.js"; import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js"; - +import { renderSignalMentions } from "./mentions.js"; export function createSignalEventHandler(deps: SignalEventHandlerDeps) { const inboundDebounceMs = resolveInboundDebounceMs({ cfg: deps.cfg, channel: "signal" }); @@ -354,20 +354,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { : null; // Replace  (object replacement character) with @uuid or @phone from mentions - let messageText = (dataMessage?.message ?? "").trim(); - if (messageText && dataMessage?.mentions?.length) { - const mentions = dataMessage.mentions - .filter((m) => (m.uuid || m.number) && m.start != null && m.length != null) - .sort((a, b) => (b.start ?? 0) - (a.start ?? 0)); // Reverse order to avoid index shifting - - for (const mention of mentions) { - const start = mention.start!; - const length = mention.length!; - const identifier = mention.uuid || mention.number || ""; - const replacement = `@${identifier}`; - messageText = messageText.slice(0, start) + replacement + messageText.slice(start + length); - } - } + // Signal encodes mentions as the object replacement character; hydrate them from metadata first. + const rawMessage = dataMessage?.message ?? ""; + const normalizedMessage = renderSignalMentions(rawMessage, dataMessage?.mentions); + const messageText = normalizedMessage.trim(); const quoteText = dataMessage?.quote?.text?.trim() ?? ""; const hasBodyContent = diff --git a/src/signal/monitor/mentions.test.ts b/src/signal/monitor/mentions.test.ts new file mode 100644 index 0000000000..b733f539d5 --- /dev/null +++ b/src/signal/monitor/mentions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { renderSignalMentions } from "./mentions.js"; + +const PLACEHOLDER = "\uFFFC"; + +describe("renderSignalMentions", () => { + it("returns the original message when no mentions are provided", () => { + const message = `${PLACEHOLDER} ping`; + expect(renderSignalMentions(message, null)).toBe(message); + expect(renderSignalMentions(message, [])).toBe(message); + }); + + it("replaces placeholder code points using mention metadata", () => { + const message = `${PLACEHOLDER} hi ${PLACEHOLDER}!`; + const normalized = renderSignalMentions(message, [ + { uuid: "abc-123", start: 0, length: 1 }, + { number: "+15550005555", start: message.lastIndexOf(PLACEHOLDER), length: 1 }, + ]); + + expect(normalized).toBe("@abc-123 hi @+15550005555!"); + }); + + it("skips mentions that lack identifiers or out-of-bounds spans", () => { + const message = `${PLACEHOLDER} hi`; + const normalized = renderSignalMentions(message, [ + { name: "ignored" }, + { uuid: "valid", start: 0, length: 1 }, + { number: "+1555", start: 999, length: 1 }, + ]); + + expect(normalized).toBe("@valid hi"); + }); +}); diff --git a/src/signal/monitor/mentions.ts b/src/signal/monitor/mentions.ts new file mode 100644 index 0000000000..2c63d0a9ed --- /dev/null +++ b/src/signal/monitor/mentions.ts @@ -0,0 +1,42 @@ +import type { SignalMention } from "./event-handler.types.js"; + +const OBJECT_REPLACEMENT = "\uFFFC"; + +function isValidMention(mention: SignalMention | null | undefined): mention is SignalMention { + if (!mention) return false; + if (!(mention.uuid || mention.number)) return false; + if (typeof mention.start !== "number" || Number.isNaN(mention.start)) return false; + if (typeof mention.length !== "number" || Number.isNaN(mention.length)) return false; + return mention.length > 0; +} + +function clampBounds(start: number, length: number, textLength: number) { + const safeStart = Math.max(0, Math.trunc(start)); + const safeLength = Math.max(0, Math.trunc(length)); + const safeEnd = Math.min(textLength, safeStart + safeLength); + return { start: safeStart, end: safeEnd }; +} + +export function renderSignalMentions(message: string, mentions?: SignalMention[] | null) { + if (!message || !mentions?.length) { + return message; + } + + let normalized = message; + const candidates = mentions.filter(isValidMention).sort((a, b) => b.start! - a.start!); + + for (const mention of candidates) { + const identifier = mention.uuid ?? mention.number; + if (!identifier) continue; + + const { start, end } = clampBounds(mention.start!, mention.length!, normalized.length); + if (start >= end) continue; + const slice = normalized.slice(start, end); + + if (!slice.includes(OBJECT_REPLACEMENT)) continue; + + normalized = normalized.slice(0, start) + `@${identifier}` + normalized.slice(end); + } + + return normalized; +}