mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:28:37 +00:00
Signal: normalize mention placeholders
This commit is contained in:
committed by
Vignesh
parent
051c574047
commit
cfec19df53
@@ -53,6 +53,14 @@ type GroupEventOpts = {
|
|||||||
message?: string;
|
message?: string;
|
||||||
attachments?: unknown[];
|
attachments?: unknown[];
|
||||||
quoteText?: string;
|
quoteText?: string;
|
||||||
|
mentions?:
|
||||||
|
| Array<{
|
||||||
|
uuid?: string;
|
||||||
|
number?: string;
|
||||||
|
start?: number;
|
||||||
|
length?: number;
|
||||||
|
}>
|
||||||
|
| null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeGroupEvent(opts: GroupEventOpts) {
|
function makeGroupEvent(opts: GroupEventOpts) {
|
||||||
@@ -67,6 +75,7 @@ function makeGroupEvent(opts: GroupEventOpts) {
|
|||||||
message: opts.message ?? "",
|
message: opts.message ?? "",
|
||||||
attachments: opts.attachments ?? [],
|
attachments: opts.attachments ?? [],
|
||||||
quote: opts.quoteText ? { text: opts.quoteText } : undefined,
|
quote: opts.quoteText ? { text: opts.quoteText } : undefined,
|
||||||
|
mentions: opts.mentions ?? undefined,
|
||||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -203,4 +212,63 @@ describe("signal mention gating", () => {
|
|||||||
await handler(makeGroupEvent({ message: "/help" }));
|
await handler(makeGroupEvent({ message: "/help" }));
|
||||||
expect(capturedCtx).toBeTruthy();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
resolveSignalSender,
|
resolveSignalSender,
|
||||||
} from "../identity.js";
|
} from "../identity.js";
|
||||||
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
|
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
|
||||||
|
import { renderSignalMentions } from "./mentions.js";
|
||||||
export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||||
const inboundDebounceMs = resolveInboundDebounceMs({ cfg: deps.cfg, channel: "signal" });
|
const inboundDebounceMs = resolveInboundDebounceMs({ cfg: deps.cfg, channel: "signal" });
|
||||||
|
|
||||||
@@ -354,20 +354,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Replace  (object replacement character) with @uuid or @phone from mentions
|
// Replace  (object replacement character) with @uuid or @phone from mentions
|
||||||
let messageText = (dataMessage?.message ?? "").trim();
|
// Signal encodes mentions as the object replacement character; hydrate them from metadata first.
|
||||||
if (messageText && dataMessage?.mentions?.length) {
|
const rawMessage = dataMessage?.message ?? "";
|
||||||
const mentions = dataMessage.mentions
|
const normalizedMessage = renderSignalMentions(rawMessage, dataMessage?.mentions);
|
||||||
.filter((m) => (m.uuid || m.number) && m.start != null && m.length != null)
|
const messageText = normalizedMessage.trim();
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const quoteText = dataMessage?.quote?.text?.trim() ?? "";
|
const quoteText = dataMessage?.quote?.text?.trim() ?? "";
|
||||||
const hasBodyContent =
|
const hasBodyContent =
|
||||||
|
|||||||
34
src/signal/monitor/mentions.test.ts
Normal file
34
src/signal/monitor/mentions.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/signal/monitor/mentions.ts
Normal file
42
src/signal/monitor/mentions.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user