Signal: normalize mention placeholders

This commit is contained in:
Vignesh Natarajan
2026-02-12 12:48:22 -08:00
committed by Vignesh
parent 051c574047
commit cfec19df53
4 changed files with 149 additions and 15 deletions

View File

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

View File

@@ -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 =

View File

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

View File

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