feat(signal): enhanced inbound handling, unsend, polls, and poll voting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4c60956d8e
commit
83fecf2f66
@ -205,6 +205,94 @@ Groups:
|
||||
- Inbound messages are normalized into the shared channel envelope.
|
||||
- Replies always route back to the same number or group.
|
||||
|
||||
## Inbound Message Features
|
||||
|
||||
Signal's enhanced inbound handling provides rich metadata for attachments, quotes, stickers, polls, and more. This metadata is available to hooks, auto-reply templates, and agent context.
|
||||
|
||||
### Multi-attachment Support
|
||||
|
||||
When a Signal message includes multiple attachments, OpenClaw populates:
|
||||
|
||||
- **`MediaPaths`**: Array of file paths for all downloaded attachments.
|
||||
- **`MediaTypes`**: Array of content types (MIME types).
|
||||
- **`MediaCaptions`**: Array of captions (when present).
|
||||
- **`MediaDimensions`**: Array of objects with `width` and `height` (when available).
|
||||
|
||||
Single-attachment messages still populate the legacy `MediaPath`, `MediaType`, and `MediaCaption` fields for backward compatibility.
|
||||
|
||||
### Stickers
|
||||
|
||||
Signal stickers are downloaded as attachments. Metadata is added to the `UntrustedContext` array:
|
||||
|
||||
- `Signal sticker packId: <id>`
|
||||
- `Signal stickerId: <id>`
|
||||
|
||||
The sticker attachment is included in `MediaPaths` along with any other attachments.
|
||||
|
||||
### Link Previews
|
||||
|
||||
When enabled (default), link preview metadata is extracted into the `UntrustedContext` array:
|
||||
|
||||
- URL, title, and description
|
||||
- Format: `Link preview: <title> - <description> (<url>)`
|
||||
|
||||
Toggle with `channels.signal.injectLinkPreviews` (default: `true`). Set to `false` to exclude link previews from context.
|
||||
|
||||
### Text Formatting
|
||||
|
||||
Signal supports rich text styles: **bold**, _italic_, `monospace`, ~~strikethrough~~, and spoiler. When enabled (default), OpenClaw applies these as markdown-style formatting to the message text.
|
||||
|
||||
Toggle with `channels.signal.preserveTextStyles` (default: `true`). Set to `false` to receive plain text without formatting markers.
|
||||
|
||||
### Quote/Reply Metadata
|
||||
|
||||
Quoted messages populate reply metadata fields:
|
||||
|
||||
- **`ReplyToId`**: Quoted message timestamp.
|
||||
- **`ReplyToBody`**: Quoted message text.
|
||||
- **`ReplyToSender`**: Quoted message author (phone number or UUID).
|
||||
- **`ReplyToIsQuote`**: `true` for Signal quotes.
|
||||
|
||||
### Shared Contacts
|
||||
|
||||
Contact cards are extracted into the `UntrustedContext` array with:
|
||||
|
||||
- Display name (given name, family name, or display name)
|
||||
- Phone numbers
|
||||
- Email addresses
|
||||
- Organization
|
||||
|
||||
Format: `Shared contact: <name> -- phone: <numbers>, email: <addresses>, org: <organization>`
|
||||
|
||||
### Polls
|
||||
|
||||
Signal polls populate the `UntrustedContext` array with:
|
||||
|
||||
- **Poll creation**: question, options, multi-select flag
|
||||
- Format: `Poll: "<question>" -- Options: <option1>, <option2>, ... (multiple selections allowed)`
|
||||
- **Poll votes**: option indexes, voter info
|
||||
- Format: `Poll vote on #<timestamp>: option(s) <index> (by <voter>)`
|
||||
- **Poll termination**: closed poll indicator
|
||||
- Format: `Poll #<timestamp> closed`
|
||||
|
||||
### Edit Tracking
|
||||
|
||||
Edited messages include:
|
||||
|
||||
- **`EditTargetTimestamp`**: Original message timestamp being edited (provider-specific format).
|
||||
- Updated message text and attachments replace the original content.
|
||||
|
||||
### UntrustedContext Field
|
||||
|
||||
The `UntrustedContext` array contains provider-extracted metadata that may not be directly part of the user's message text:
|
||||
|
||||
- Sticker metadata
|
||||
- Link preview details
|
||||
- Shared contact information
|
||||
- Poll data (create, vote, terminate)
|
||||
|
||||
This field is available in hooks, auto-reply templates, and agent context. Use it to provide additional context without altering the primary message body.
|
||||
|
||||
## Media + limits
|
||||
|
||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
||||
@ -321,6 +409,8 @@ Provider options:
|
||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.signal.injectLinkPreviews`: extract link preview metadata into UntrustedContext (default: true). Set `false` to exclude link previews from message context.
|
||||
- `channels.signal.preserveTextStyles`: apply Signal text styles (bold/italic/monospace/strikethrough/spoiler) to message text as markdown formatting (default: true). Set `false` to receive plain text.
|
||||
|
||||
Related global options:
|
||||
|
||||
|
||||
@ -504,6 +504,13 @@ When Mattermost native commands are enabled:
|
||||
- `channels.signal.configWrites`: allow or deny Signal-initiated config writes.
|
||||
- Optional `channels.signal.defaultAccount` overrides default account selection when it matches a configured account id.
|
||||
|
||||
**Inbound message processing:**
|
||||
|
||||
- `injectLinkPreviews` (default: `true`): extract Signal link preview metadata into `UntrustedContext`.
|
||||
- `preserveTextStyles` (default: `true`): convert Signal rich text styles into markdown-style formatting in the inbound text.
|
||||
|
||||
See [Signal channel docs](/channels/signal#inbound-message-features) for full inbound message feature details (multi-attachments, stickers, polls, quotes, contacts, edits).
|
||||
|
||||
### BlueBubbles
|
||||
|
||||
BlueBubbles is the recommended iMessage path (plugin-backed, configured under `channels.bluebubbles`).
|
||||
|
||||
@ -377,6 +377,8 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||
const injectLinkPreviews = accountInfo.config.injectLinkPreviews ?? true;
|
||||
const preserveTextStyles = accountInfo.config.preserveTextStyles ?? true;
|
||||
const waitForTransportReadyFn = opts.waitForTransportReady ?? waitForTransportReady;
|
||||
|
||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
||||
@ -449,6 +451,8 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
ignoreAttachments,
|
||||
sendReadReceipts,
|
||||
readReceiptsViaDaemon,
|
||||
injectLinkPreviews,
|
||||
preserveTextStyles,
|
||||
fetchAttachment,
|
||||
deliverReplies: (params) => deliverReplies({ ...params, chunkMode }),
|
||||
resolveSignalReactionTargets,
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
createSignalReceiveEvent,
|
||||
} from "./event-handler.test-harness.js";
|
||||
import type { SignalEventHandlerDeps } from "./event-handler.types.js";
|
||||
|
||||
const { sendTypingMock, sendReadReceiptMock, dispatchInboundMessageMock, capture } = vi.hoisted(
|
||||
() => {
|
||||
@ -49,6 +50,39 @@ vi.mock("../../../../src/pairing/pairing-store.js", () => ({
|
||||
upsertChannelPairingRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
function createEnhancedTestHandler(overrides: Partial<SignalEventHandlerDeps> = {}) {
|
||||
return createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
|
||||
historyLimit: 0,
|
||||
injectLinkPreviews: true,
|
||||
preserveTextStyles: true,
|
||||
...overrides,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function makeEnhancedReceiveEvent(
|
||||
dataMessage: Record<string, unknown>,
|
||||
envelopeOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return createSignalReceiveEvent({
|
||||
...envelopeOverrides,
|
||||
dataMessage: {
|
||||
message: "",
|
||||
attachments: [],
|
||||
...dataMessage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function requireCapturedCtx() {
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expectInboundContextContract(capture.ctx!);
|
||||
return capture.ctx!;
|
||||
}
|
||||
|
||||
describe("signal createSignalEventHandler inbound context", () => {
|
||||
beforeEach(() => {
|
||||
capture.ctx = undefined;
|
||||
@ -260,3 +294,259 @@ describe("signal createSignalEventHandler inbound context", () => {
|
||||
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signal enhanced inbound contract coverage", () => {
|
||||
beforeEach(() => {
|
||||
capture.ctx = undefined;
|
||||
dispatchInboundMessageMock.mockClear();
|
||||
});
|
||||
|
||||
it("maps quote metadata to reply context fields", async () => {
|
||||
const handler = createEnhancedTestHandler();
|
||||
|
||||
await handler(
|
||||
makeEnhancedReceiveEvent({
|
||||
message: "reply with quote",
|
||||
quote: {
|
||||
id: 9001,
|
||||
text: "original message",
|
||||
authorUuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.ReplyToId).toBe("9001");
|
||||
expect(ctx.ReplyToSender).toBe("123e4567-e89b-12d3-a456-426614174000");
|
||||
expect(ctx.ReplyToBody).toBe("original message");
|
||||
expect(ctx.ReplyToIsQuote).toBe(true);
|
||||
});
|
||||
|
||||
it("maps attachment captions and dimensions into media aliases and arrays", async () => {
|
||||
const handler = createEnhancedTestHandler({
|
||||
ignoreAttachments: false,
|
||||
fetchAttachment: async ({ attachment }) => ({
|
||||
path: `/tmp/${String(attachment.id)}.jpg`,
|
||||
contentType: attachment.id === "att-1" ? "image/jpeg" : "image/png",
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
makeEnhancedReceiveEvent({
|
||||
attachments: [
|
||||
{ id: "att-1", contentType: "image/jpeg", caption: "cover", width: 128.4, height: 64.2 },
|
||||
{ id: "att-2", contentType: "image/png", width: 320.9, height: 240.1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.MediaPath).toBe("/tmp/att-1.jpg");
|
||||
expect(ctx.MediaCaption).toBe("cover");
|
||||
expect(ctx.MediaCaptions).toEqual(["cover", ""]);
|
||||
expect(ctx.MediaDimension).toEqual({ width: 128, height: 64 });
|
||||
expect(ctx.MediaDimensions).toEqual([
|
||||
{ width: 128, height: 64 },
|
||||
{ width: 321, height: 240 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces sticker metadata and placeholder context", async () => {
|
||||
const handler = createEnhancedTestHandler({
|
||||
ignoreAttachments: false,
|
||||
fetchAttachment: async () => ({
|
||||
path: "/tmp/sticker.webp",
|
||||
contentType: "image/webp",
|
||||
}),
|
||||
});
|
||||
|
||||
await handler(
|
||||
makeEnhancedReceiveEvent({
|
||||
sticker: {
|
||||
packId: "pack-1",
|
||||
stickerId: "7",
|
||||
attachment: { id: "sticker-1", contentType: "image/webp", width: 512, height: 256 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.BodyForCommands).toBe("<media:sticker>");
|
||||
expect(ctx.MediaPath).toBe("/tmp/sticker.webp");
|
||||
expect(ctx.MediaDimension).toEqual({ width: 512, height: 256 });
|
||||
expect(ctx.UntrustedContext).toContain("Signal sticker packId: pack-1");
|
||||
expect(ctx.UntrustedContext).toContain("Signal stickerId: 7");
|
||||
});
|
||||
|
||||
it("injects link previews by default and can disable them", async () => {
|
||||
const preview = [
|
||||
{
|
||||
url: "https://example.com/post",
|
||||
title: "Example Post",
|
||||
description: "A useful summary",
|
||||
},
|
||||
];
|
||||
|
||||
await createEnhancedTestHandler()(
|
||||
makeEnhancedReceiveEvent({
|
||||
message: "check this",
|
||||
previews: preview,
|
||||
}),
|
||||
);
|
||||
expect(requireCapturedCtx().UntrustedContext).toContain(
|
||||
"Link preview: Example Post - A useful summary (https://example.com/post)",
|
||||
);
|
||||
|
||||
capture.ctx = undefined;
|
||||
await createEnhancedTestHandler({ injectLinkPreviews: false })(
|
||||
makeEnhancedReceiveEvent({
|
||||
message: "check this",
|
||||
previews: preview,
|
||||
}),
|
||||
);
|
||||
|
||||
const disabledCtx = requireCapturedCtx();
|
||||
expect(disabledCtx.UntrustedContext ?? []).not.toContain(
|
||||
"Link preview: Example Post - A useful summary (https://example.com/post)",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies text styles and keeps spans aligned after mention expansion", async () => {
|
||||
const handler = createEnhancedTestHandler();
|
||||
|
||||
await handler(
|
||||
makeEnhancedReceiveEvent({
|
||||
message: "\uFFFC check this out",
|
||||
mentions: [
|
||||
{
|
||||
uuid: "550e8400-e29b-41d4-a716-446655440000",
|
||||
start: 0,
|
||||
length: 1,
|
||||
},
|
||||
],
|
||||
textStyles: [{ style: "BOLD", start: 2, length: 5 }],
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.BodyForCommands).toBe("@550e8400-e29b-41d4-a716-446655440000 **check** this out");
|
||||
});
|
||||
|
||||
it("respects preserveTextStyles false", async () => {
|
||||
const handler = createEnhancedTestHandler({ preserveTextStyles: false });
|
||||
|
||||
await handler(
|
||||
makeEnhancedReceiveEvent({
|
||||
message: "hello world",
|
||||
textStyles: [
|
||||
{ style: "BOLD", start: 0, length: 5 },
|
||||
{ style: "ITALIC", start: 6, length: 5 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.BodyForCommands).toBe("hello world");
|
||||
});
|
||||
|
||||
it("adds shared contacts as untrusted context with a contact placeholder", async () => {
|
||||
const handler = createEnhancedTestHandler();
|
||||
|
||||
await handler(
|
||||
makeEnhancedReceiveEvent({
|
||||
contacts: [
|
||||
{
|
||||
name: { display: "Jane Doe", given: "Jane", family: "Doe" },
|
||||
phone: [{ value: "+15551234567", type: "mobile" }],
|
||||
email: [{ value: "jane@example.com", type: "work" }],
|
||||
organization: "Acme Corp",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.BodyForCommands).toBe("<media:contact>");
|
||||
expect(ctx.UntrustedContext).toContain(
|
||||
"Shared contact: Jane Doe (+15551234567, jane@example.com, Acme Corp)",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures edit target timestamps from Signal edit envelopes", async () => {
|
||||
const handler = createEnhancedTestHandler();
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: undefined,
|
||||
editMessage: {
|
||||
targetSentTimestamp: 1234567890,
|
||||
dataMessage: {
|
||||
message: "edited text",
|
||||
attachments: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.EditTargetTimestamp).toBe(1234567890);
|
||||
expect(ctx.BodyForCommands).toBe("edited text");
|
||||
});
|
||||
|
||||
it("renders poll creation context and placeholder", async () => {
|
||||
const handler = createEnhancedTestHandler();
|
||||
|
||||
await handler(
|
||||
makeEnhancedReceiveEvent({
|
||||
pollCreate: {
|
||||
question: "What's for lunch?",
|
||||
allowMultiple: false,
|
||||
options: ["Pizza", "Sushi", "Tacos"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.BodyForCommands).toBe("[Poll] What's for lunch?");
|
||||
expect(ctx.UntrustedContext).toContain(
|
||||
'Poll: "What\'s for lunch?" — Options: Pizza, Sushi, Tacos',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders poll vote context without leaking author metadata", async () => {
|
||||
const handler = createEnhancedTestHandler();
|
||||
|
||||
await handler(
|
||||
makeEnhancedReceiveEvent({
|
||||
pollVote: {
|
||||
authorNumber: null,
|
||||
authorUuid: "abc-123-uuid",
|
||||
targetSentTimestamp: 1234567890,
|
||||
optionIndexes: [0, 2],
|
||||
voteCount: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.BodyForCommands).toBe("[Poll vote]");
|
||||
expect(ctx.UntrustedContext).toContain("Poll vote on #1234567890: option(s) 0, 2");
|
||||
expect((ctx.UntrustedContext ?? []).join("\n")).not.toContain("abc-123-uuid");
|
||||
});
|
||||
|
||||
it("renders poll termination context", async () => {
|
||||
const handler = createEnhancedTestHandler();
|
||||
|
||||
await handler(
|
||||
makeEnhancedReceiveEvent({
|
||||
pollTerminate: {
|
||||
targetSentTimestamp: 1234567890,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const ctx = requireCapturedCtx();
|
||||
expect(ctx.BodyForCommands).toBe("[Poll closed]");
|
||||
expect(ctx.UntrustedContext).toContain("Poll #1234567890 closed");
|
||||
});
|
||||
});
|
||||
|
||||
@ -265,35 +265,37 @@ 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);
|
||||
expect(renderSignalMentions(message, null).text).toBe(message);
|
||||
expect(renderSignalMentions(message, []).text).toBe(message);
|
||||
expect(renderSignalMentions(message, null).offsetShifts.size).toBe(0);
|
||||
});
|
||||
|
||||
it("replaces placeholder code points using mention metadata", () => {
|
||||
const message = `${PLACEHOLDER} hi ${PLACEHOLDER}!`;
|
||||
const normalized = renderSignalMentions(message, [
|
||||
const result = renderSignalMentions(message, [
|
||||
{ uuid: "abc-123", start: 0, length: 1 },
|
||||
{ number: "+15550005555", start: message.lastIndexOf(PLACEHOLDER), length: 1 },
|
||||
]);
|
||||
|
||||
expect(normalized).toBe("@abc-123 hi @+15550005555!");
|
||||
expect(result.text).toBe("@abc-123 hi @+15550005555!");
|
||||
expect(result.offsetShifts.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips mentions that lack identifiers or out-of-bounds spans", () => {
|
||||
const message = `${PLACEHOLDER} hi`;
|
||||
const normalized = renderSignalMentions(message, [
|
||||
const result = renderSignalMentions(message, [
|
||||
{ name: "ignored" },
|
||||
{ uuid: "valid", start: 0, length: 1 },
|
||||
{ number: "+1555", start: 999, length: 1 },
|
||||
]);
|
||||
|
||||
expect(normalized).toBe("@valid hi");
|
||||
expect(result.text).toBe("@valid hi");
|
||||
});
|
||||
|
||||
it("clamps and truncates fractional mention offsets", () => {
|
||||
const message = `${PLACEHOLDER} ping`;
|
||||
const normalized = renderSignalMentions(message, [{ uuid: "valid", start: -0.7, length: 1.9 }]);
|
||||
const result = renderSignalMentions(message, [{ uuid: "valid", start: -0.7, length: 1.9 }]);
|
||||
|
||||
expect(normalized).toBe("@valid ping");
|
||||
expect(result.text).toBe("@valid ping");
|
||||
});
|
||||
});
|
||||
|
||||
@ -22,6 +22,8 @@ export function createBaseSignalEventHandlerDeps(
|
||||
ignoreAttachments: true,
|
||||
sendReadReceipts: false,
|
||||
readReceiptsViaDaemon: false,
|
||||
injectLinkPreviews: true,
|
||||
preserveTextStyles: true,
|
||||
fetchAttachment: async () => null,
|
||||
deliverReplies: async () => {},
|
||||
resolveSignalReactionTargets: () => [],
|
||||
|
||||
@ -49,13 +49,213 @@ import {
|
||||
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
|
||||
import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js";
|
||||
import type {
|
||||
SignalEnvelope,
|
||||
SignalEventHandlerDeps,
|
||||
SignalReactionMessage,
|
||||
SignalReceivePayload,
|
||||
SignalTextStyleRange,
|
||||
} from "./event-handler.types.js";
|
||||
import type { SignalEnvelope, SignalReactionMessage } from "./event-handler.types.js";
|
||||
import { renderSignalMentions } from "./mentions.js";
|
||||
|
||||
function normalizeDimensionValue(value?: number | null): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function normalizeCaptionValue(value?: string | null): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
const SIGNAL_MARKDOWN_STYLE_MARKERS: Record<string, { open: string; close: string }> = {
|
||||
BOLD: { open: "**", close: "**" },
|
||||
ITALIC: { open: "_", close: "_" },
|
||||
MONOSPACE: { open: "`", close: "`" },
|
||||
STRIKETHROUGH: { open: "~~", close: "~~" },
|
||||
SPOILER: { open: "||", close: "||" },
|
||||
};
|
||||
|
||||
function applySignalTextStyles(text: string, styles?: SignalTextStyleRange[] | null): string {
|
||||
if (!text || !Array.isArray(styles) || styles.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const opens = new Map<number, string[]>();
|
||||
const closes = new Map<number, string[]>();
|
||||
|
||||
const normalizedRanges = styles
|
||||
.map((style) => {
|
||||
const marker = style.style ? SIGNAL_MARKDOWN_STYLE_MARKERS[style.style] : undefined;
|
||||
if (!marker) {
|
||||
return null;
|
||||
}
|
||||
if (typeof style.start !== "number" || typeof style.length !== "number") {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(style.start) || !Number.isFinite(style.length)) {
|
||||
return null;
|
||||
}
|
||||
const start = Math.max(0, Math.trunc(style.start));
|
||||
const length = Math.max(0, Math.trunc(style.length));
|
||||
if (length <= 0 || start >= text.length) {
|
||||
return null;
|
||||
}
|
||||
const end = Math.min(text.length, start + length);
|
||||
if (end <= start) {
|
||||
return null;
|
||||
}
|
||||
return { start, end, marker };
|
||||
})
|
||||
.filter(
|
||||
(range): range is { start: number; end: number; marker: { open: string; close: string } } =>
|
||||
Boolean(range),
|
||||
)
|
||||
.toSorted((a, b) => {
|
||||
if (a.start !== b.start) {
|
||||
return b.start - a.start;
|
||||
}
|
||||
return b.end - a.end;
|
||||
});
|
||||
|
||||
for (const range of normalizedRanges) {
|
||||
const openList = opens.get(range.start) ?? [];
|
||||
openList.push(range.marker.open);
|
||||
opens.set(range.start, openList);
|
||||
|
||||
const closeList = closes.get(range.end) ?? [];
|
||||
closeList.push(range.marker.close);
|
||||
closes.set(range.end, closeList);
|
||||
}
|
||||
|
||||
let output = text;
|
||||
for (let index = text.length; index >= 0; index -= 1) {
|
||||
const closeList = closes.get(index);
|
||||
const openList = opens.get(index);
|
||||
if (!closeList && !openList) {
|
||||
continue;
|
||||
}
|
||||
const insertion = `${(closeList ?? []).join("")}${(openList ?? []).join("")}`;
|
||||
output = `${output.slice(0, index)}${insertion}${output.slice(index)}`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildSignalLinkPreviewContext(
|
||||
previews?: Array<{
|
||||
url?: string | null;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
}> | null,
|
||||
): string[] {
|
||||
if (!Array.isArray(previews) || previews.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const context: string[] = [];
|
||||
for (const preview of previews) {
|
||||
const url = preview.url?.trim();
|
||||
if (!url) {
|
||||
continue;
|
||||
}
|
||||
const title = preview.title?.trim();
|
||||
const description = preview.description?.trim();
|
||||
const label = title && description ? `${title} - ${description}` : title || description || url;
|
||||
context.push(`Link preview: ${label} (${url})`);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function buildSignalContactContext(
|
||||
contacts?: Array<{
|
||||
name?: { display?: string | null; given?: string | null; family?: string | null } | null;
|
||||
phone?: Array<{ value?: string | null; type?: string | null }> | null;
|
||||
email?: Array<{ value?: string | null; type?: string | null }> | null;
|
||||
organization?: string | null;
|
||||
}> | null,
|
||||
): string[] {
|
||||
if (!Array.isArray(contacts) || contacts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const context: string[] = [];
|
||||
for (const contact of contacts) {
|
||||
const displayName =
|
||||
contact.name?.display?.trim() ||
|
||||
`${contact.name?.given?.trim() ?? ""} ${contact.name?.family?.trim() ?? ""}`.trim() ||
|
||||
"Unknown";
|
||||
const phone = contact.phone?.[0]?.value?.trim();
|
||||
const email = contact.email?.[0]?.value?.trim();
|
||||
const organization = contact.organization?.trim();
|
||||
|
||||
const details = [phone, email, organization].filter(Boolean).join(", ");
|
||||
if (!details && displayName === "Unknown") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = details ? `${displayName} (${details})` : displayName;
|
||||
context.push(`Shared contact: ${label}`);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function buildSignalPollContext(params: {
|
||||
pollCreate?: {
|
||||
question?: string | null;
|
||||
allowMultiple?: boolean | null;
|
||||
options?: string[] | null;
|
||||
} | null;
|
||||
pollVote?: {
|
||||
authorNumber?: string | null;
|
||||
authorUuid?: string | null;
|
||||
targetSentTimestamp?: number | null;
|
||||
optionIndexes?: number[] | null;
|
||||
voteCount?: number | null;
|
||||
} | null;
|
||||
pollTerminate?: { targetSentTimestamp?: number | null } | null;
|
||||
}): string[] {
|
||||
const context: string[] = [];
|
||||
|
||||
if (params.pollCreate) {
|
||||
const question = params.pollCreate.question?.trim() || "Untitled";
|
||||
const options =
|
||||
params.pollCreate.options?.filter((opt) => opt?.trim()).map((opt) => opt.trim()) ?? [];
|
||||
const allowMultiple = params.pollCreate.allowMultiple === true;
|
||||
|
||||
if (options.length > 0) {
|
||||
const optionsText = options.join(", ");
|
||||
const suffix = allowMultiple ? " (multiple selections allowed)" : "";
|
||||
context.push(`Poll: "${question}" — Options: ${optionsText}${suffix}`);
|
||||
} else {
|
||||
context.push(`Poll: "${question}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.pollVote) {
|
||||
const targetTimestamp = params.pollVote.targetSentTimestamp;
|
||||
const indexes = params.pollVote.optionIndexes?.filter((idx) => typeof idx === "number") ?? [];
|
||||
|
||||
if (targetTimestamp != null) {
|
||||
const timestampText = `#${targetTimestamp}`;
|
||||
const indexesText = indexes.length > 0 ? indexes.join(", ") : "unknown";
|
||||
context.push(`Poll vote on ${timestampText}: option(s) ${indexesText}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.pollTerminate) {
|
||||
const targetTimestamp = params.pollTerminate.targetSentTimestamp;
|
||||
if (targetTimestamp != null) {
|
||||
context.push(`Poll #${targetTimestamp} closed`);
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function formatAttachmentKindCount(kind: string, count: number): string {
|
||||
if (kind === "attachment") {
|
||||
return `${count} file${count > 1 ? "s" : ""}`;
|
||||
@ -104,12 +304,24 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
isGroup: boolean;
|
||||
bodyText: string;
|
||||
commandBody: string;
|
||||
bodyTextPlain: string;
|
||||
timestamp?: number;
|
||||
messageId?: string;
|
||||
editTargetTimestamp?: number;
|
||||
isEdit?: boolean;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
mediaCaption?: string;
|
||||
mediaPaths?: string[];
|
||||
mediaTypes?: string[];
|
||||
mediaCaptions?: string[];
|
||||
mediaDimension?: { width?: number; height?: number };
|
||||
mediaDimensions?: Array<{ width?: number; height?: number }>;
|
||||
untrustedContext?: string[];
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
replyToIsQuote?: boolean;
|
||||
commandAuthorized: boolean;
|
||||
wasMentioned?: boolean;
|
||||
};
|
||||
@ -203,13 +415,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
Provider: "signal" as const,
|
||||
Surface: "signal" as const,
|
||||
MessageSid: entry.messageId,
|
||||
EditTargetTimestamp: entry.editTargetTimestamp,
|
||||
ReplyToId: entry.replyToId,
|
||||
ReplyToBody: entry.replyToBody,
|
||||
ReplyToSender: entry.replyToSender,
|
||||
ReplyToIsQuote: entry.replyToIsQuote,
|
||||
UntrustedContext: entry.untrustedContext,
|
||||
Timestamp: entry.timestamp ?? undefined,
|
||||
MediaPath: entry.mediaPath,
|
||||
MediaType: entry.mediaType,
|
||||
MediaCaption: entry.mediaCaption,
|
||||
MediaUrl: entry.mediaPath,
|
||||
MediaPaths: entry.mediaPaths,
|
||||
MediaUrls: entry.mediaPaths,
|
||||
MediaTypes: entry.mediaTypes,
|
||||
MediaCaptions: entry.mediaCaptions,
|
||||
MediaDimension: entry.mediaDimension,
|
||||
MediaDimensions: entry.mediaDimensions,
|
||||
WasMentioned: entry.isGroup ? entry.wasMentioned === true : undefined,
|
||||
CommandAuthorized: entry.commandAuthorized,
|
||||
OriginatingChannel: "signal" as const,
|
||||
@ -348,9 +570,17 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
return shouldDebounceTextInbound({
|
||||
text: entry.bodyText,
|
||||
text: entry.bodyTextPlain,
|
||||
cfg: deps.cfg,
|
||||
hasMedia: Boolean(entry.mediaPath || entry.mediaType || entry.mediaPaths?.length),
|
||||
allowDebounce: !entry.isEdit,
|
||||
hasMedia: Boolean(
|
||||
entry.mediaPath ||
|
||||
entry.mediaType ||
|
||||
entry.mediaCaption ||
|
||||
entry.mediaPaths?.length ||
|
||||
entry.mediaTypes?.length ||
|
||||
entry.mediaCaptions?.length,
|
||||
),
|
||||
});
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
@ -366,16 +596,41 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
.map((entry) => entry.bodyText)
|
||||
.filter(Boolean)
|
||||
.join("\\n");
|
||||
const combinedTextPlain = entries
|
||||
.map((entry) => entry.bodyTextPlain)
|
||||
.filter(Boolean)
|
||||
.join("\\n");
|
||||
if (!combinedText.trim()) {
|
||||
return;
|
||||
}
|
||||
// Merge untrustedContext arrays from all entries so poll vote metadata,
|
||||
// link previews, sticker context etc. survive debounce concatenation.
|
||||
const mergedUntrustedContext = entries.reduce<string[]>((acc, entry) => {
|
||||
if (Array.isArray(entry.untrustedContext)) {
|
||||
acc.push(...entry.untrustedContext);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
await handleSignalInboundMessage({
|
||||
...last,
|
||||
bodyText: combinedText,
|
||||
commandBody: combinedText,
|
||||
bodyTextPlain: combinedTextPlain,
|
||||
mediaPath: undefined,
|
||||
mediaType: undefined,
|
||||
mediaCaption: undefined,
|
||||
mediaPaths: undefined,
|
||||
mediaTypes: undefined,
|
||||
mediaCaptions: undefined,
|
||||
mediaDimension: undefined,
|
||||
mediaDimensions: undefined,
|
||||
untrustedContext: mergedUntrustedContext.length > 0 ? mergedUntrustedContext : undefined,
|
||||
replyToId: undefined,
|
||||
replyToBody: undefined,
|
||||
replyToSender: undefined,
|
||||
replyToIsQuote: undefined,
|
||||
editTargetTimestamp: undefined,
|
||||
isEdit: undefined,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
@ -506,6 +761,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
}
|
||||
|
||||
const dataMessage = envelope.dataMessage ?? envelope.editMessage?.dataMessage;
|
||||
const editTargetTimestamp =
|
||||
typeof envelope.editMessage?.targetSentTimestamp === "number" &&
|
||||
Number.isFinite(envelope.editMessage.targetSentTimestamp)
|
||||
? envelope.editMessage.targetSentTimestamp
|
||||
: undefined;
|
||||
const isEditMessage = Boolean(envelope.editMessage);
|
||||
const reaction = deps.isSignalReactionMessage(envelope.reactionMessage)
|
||||
? envelope.reactionMessage
|
||||
: deps.isSignalReactionMessage(dataMessage?.reaction)
|
||||
@ -515,12 +776,108 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
// Replace  (object replacement character) with @uuid or @phone from mentions
|
||||
// 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 mentionResult = renderSignalMentions(rawMessage, dataMessage?.mentions);
|
||||
const normalizedMessage = mentionResult.text;
|
||||
|
||||
const quoteText = dataMessage?.quote?.text?.trim() ?? "";
|
||||
// Adjust text style offsets to account for mention expansions
|
||||
// textStyles from Signal reference the original message offsets, but we need them
|
||||
// to reference the expanded message (after @uuid replacements)
|
||||
const adjustedTextStyles =
|
||||
dataMessage?.textStyles && mentionResult.offsetShifts.size > 0
|
||||
? (() => {
|
||||
const sortedShiftPositions = Array.from(mentionResult.offsetShifts.keys()).toSorted(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const cumulativeShiftAtOffset = (offset: number): number => {
|
||||
let cumulativeShift = 0;
|
||||
for (const shiftPos of sortedShiftPositions) {
|
||||
if (shiftPos <= offset) {
|
||||
cumulativeShift += mentionResult.offsetShifts.get(shiftPos) ?? 0;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return cumulativeShift;
|
||||
};
|
||||
return dataMessage.textStyles.map((style) => {
|
||||
if (typeof style.start !== "number") {
|
||||
return style;
|
||||
}
|
||||
const adjustedStart = style.start + cumulativeShiftAtOffset(style.start);
|
||||
if (typeof style.length !== "number") {
|
||||
return {
|
||||
...style,
|
||||
start: adjustedStart,
|
||||
};
|
||||
}
|
||||
const styleEnd = style.start + style.length;
|
||||
const adjustedEnd = styleEnd + cumulativeShiftAtOffset(styleEnd);
|
||||
return {
|
||||
...style,
|
||||
start: adjustedStart,
|
||||
length: Math.max(0, adjustedEnd - adjustedStart),
|
||||
};
|
||||
});
|
||||
})()
|
||||
: dataMessage?.textStyles;
|
||||
|
||||
const styledMessage =
|
||||
deps.preserveTextStyles !== false
|
||||
? applySignalTextStyles(normalizedMessage, adjustedTextStyles)
|
||||
: normalizedMessage;
|
||||
const messageTextPlain = normalizedMessage.trim();
|
||||
const messageText = styledMessage.trim();
|
||||
|
||||
const quote = dataMessage?.quote;
|
||||
const quoteText = quote?.text?.trim() ?? "";
|
||||
const quoteReplyId = (() => {
|
||||
const raw = quote?.id ?? quote?.timestamp;
|
||||
if (raw == null) {
|
||||
return undefined;
|
||||
}
|
||||
const value = String(raw).trim();
|
||||
return value || undefined;
|
||||
})();
|
||||
const quoteReplySender = (() => {
|
||||
const raw = quote?.authorUuid ?? quote?.authorNumber ?? quote?.author;
|
||||
if (typeof raw !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const value = raw.trim();
|
||||
return value || undefined;
|
||||
})();
|
||||
const sticker = dataMessage?.sticker;
|
||||
const stickerPackId = (() => {
|
||||
const raw = sticker?.packId;
|
||||
if (raw == null) {
|
||||
return undefined;
|
||||
}
|
||||
const value = String(raw).trim();
|
||||
return value || undefined;
|
||||
})();
|
||||
const stickerId = (() => {
|
||||
const raw = sticker?.stickerId;
|
||||
if (raw == null) {
|
||||
return undefined;
|
||||
}
|
||||
const value = String(raw).trim();
|
||||
return value || undefined;
|
||||
})();
|
||||
const stickerContext = [
|
||||
stickerPackId ? `Signal sticker packId: ${stickerPackId}` : undefined,
|
||||
stickerId ? `Signal stickerId: ${stickerId}` : undefined,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const linkPreviewContext =
|
||||
deps.injectLinkPreviews !== false ? buildSignalLinkPreviewContext(dataMessage?.previews) : [];
|
||||
const contactContext = buildSignalContactContext(dataMessage?.contacts);
|
||||
const pollCreate = dataMessage?.pollCreate ?? null;
|
||||
const pollVote = dataMessage?.pollVote ?? null;
|
||||
const pollTerminate = dataMessage?.pollTerminate ?? null;
|
||||
const pollContext = buildSignalPollContext({ pollCreate, pollVote, pollTerminate });
|
||||
const attachments = dataMessage?.attachments ?? [];
|
||||
const allAttachments = sticker?.attachment ? [...attachments, sticker.attachment] : attachments;
|
||||
const hasBodyContent =
|
||||
Boolean(messageText || quoteText) || Boolean(!reaction && dataMessage?.attachments?.length);
|
||||
Boolean(messageText || quoteText) || Boolean(!reaction && allAttachments.length > 0);
|
||||
const senderDisplay = formatSignalSenderDisplay(sender);
|
||||
const { resolveAccessDecision, dmAccess, effectiveDmAllow, effectiveGroupAllow } =
|
||||
await resolveSignalAccessState({
|
||||
@ -601,7 +958,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
const commandDmAllow = isGroup ? deps.allowFrom : effectiveDmAllow;
|
||||
const ownerAllowedForCommands = isSignalSenderAllowed(sender, commandDmAllow);
|
||||
const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow);
|
||||
const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg);
|
||||
const hasControlCommandInMessage = hasControlCommand(messageTextPlain, deps.cfg);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
@ -630,7 +987,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
senderPeerId,
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(deps.cfg, route.agentId);
|
||||
const wasMentioned = isGroup && matchesMentionPatterns(messageText, mentionRegexes);
|
||||
const wasMentioned = isGroup && matchesMentionPatterns(messageTextPlain, mentionRegexes);
|
||||
const requireMention =
|
||||
isGroup &&
|
||||
resolveChannelGroupRequireMention({
|
||||
@ -659,9 +1016,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
reason: "no mention",
|
||||
target: senderDisplay,
|
||||
});
|
||||
const quoteText = dataMessage.quote?.text?.trim() || "";
|
||||
const pendingPlaceholder = (() => {
|
||||
if (!dataMessage.attachments?.length) {
|
||||
if (dataMessage.sticker) {
|
||||
return "<media:sticker>";
|
||||
}
|
||||
if (allAttachments.length === 0) {
|
||||
if (Array.isArray(dataMessage.contacts) && dataMessage.contacts.length > 0) {
|
||||
return "<media:contact>";
|
||||
}
|
||||
if (pollCreate) {
|
||||
return `[Poll] ${pollCreate.question?.trim() || "Untitled"}`;
|
||||
}
|
||||
if (pollVote) {
|
||||
return "[Poll vote]";
|
||||
}
|
||||
if (pollTerminate) {
|
||||
return "[Poll closed]";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
// When we're skipping a message we intentionally avoid downloading attachments.
|
||||
@ -669,13 +1040,13 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
if (deps.ignoreAttachments) {
|
||||
return "<media:attachment>";
|
||||
}
|
||||
const attachmentTypes = (dataMessage.attachments ?? []).map((attachment) =>
|
||||
const attachmentTypes = allAttachments.map((attachment) =>
|
||||
typeof attachment?.contentType === "string" ? attachment.contentType : undefined,
|
||||
);
|
||||
if (attachmentTypes.length > 1) {
|
||||
return formatAttachmentSummaryPlaceholder(attachmentTypes);
|
||||
}
|
||||
const firstContentType = dataMessage.attachments?.[0]?.contentType;
|
||||
const firstContentType = allAttachments[0]?.contentType;
|
||||
const pendingKind = kindFromMime(firstContentType ?? undefined);
|
||||
return pendingKind ? `<media:${pendingKind}>` : "<media:attachment>";
|
||||
})();
|
||||
@ -698,12 +1069,22 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
const mediaPaths: string[] = [];
|
||||
const mediaTypes: string[] = [];
|
||||
let mediaCaption: string | undefined;
|
||||
let mediaPaths: string[] | undefined;
|
||||
let mediaTypes: string[] | undefined;
|
||||
let mediaCaptions: string[] | undefined;
|
||||
let mediaDimension: { width?: number; height?: number } | undefined;
|
||||
let mediaDimensions: Array<{ width?: number; height?: number }> | undefined;
|
||||
let placeholder = "";
|
||||
const attachments = dataMessage.attachments ?? [];
|
||||
if (!deps.ignoreAttachments) {
|
||||
for (const attachment of attachments) {
|
||||
if (!deps.ignoreAttachments && allAttachments.length > 0) {
|
||||
const fetchedMedia: Array<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
caption?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}> = [];
|
||||
for (const attachment of allAttachments) {
|
||||
if (!attachment?.id) {
|
||||
continue;
|
||||
}
|
||||
@ -716,34 +1097,68 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
groupId,
|
||||
maxBytes: deps.mediaMaxBytes,
|
||||
});
|
||||
if (fetched) {
|
||||
mediaPaths.push(fetched.path);
|
||||
mediaTypes.push(
|
||||
fetched.contentType ?? attachment.contentType ?? "application/octet-stream",
|
||||
);
|
||||
if (!mediaPath) {
|
||||
mediaPath = fetched.path;
|
||||
mediaType = fetched.contentType ?? attachment.contentType ?? undefined;
|
||||
}
|
||||
if (!fetched) {
|
||||
continue;
|
||||
}
|
||||
fetchedMedia.push({
|
||||
path: fetched.path,
|
||||
contentType: fetched.contentType ?? attachment.contentType ?? undefined,
|
||||
caption: normalizeCaptionValue(attachment.caption),
|
||||
width: normalizeDimensionValue(attachment.width),
|
||||
height: normalizeDimensionValue(attachment.height),
|
||||
});
|
||||
} catch (err) {
|
||||
deps.runtime.error?.(danger(`attachment fetch failed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
mediaPath = fetchedMedia[0]?.path;
|
||||
mediaType =
|
||||
fetchedMedia.length > 0
|
||||
? (fetchedMedia[0]?.contentType ?? "application/octet-stream")
|
||||
: undefined;
|
||||
mediaCaption = fetchedMedia[0]?.caption;
|
||||
mediaPaths = fetchedMedia.length > 0 ? fetchedMedia.map((entry) => entry.path) : undefined;
|
||||
mediaTypes =
|
||||
fetchedMedia.length > 0
|
||||
? fetchedMedia.map((entry) => entry.contentType ?? "application/octet-stream")
|
||||
: undefined;
|
||||
mediaCaptions =
|
||||
fetchedMedia.length > 0 ? fetchedMedia.map((entry) => entry.caption ?? "") : undefined;
|
||||
if (mediaCaptions && !mediaCaptions.some((entry) => entry.trim().length > 0)) {
|
||||
mediaCaptions = undefined;
|
||||
}
|
||||
const fetchedDimensions = fetchedMedia.map((entry) => ({
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
}));
|
||||
const hasAnyDimensions = fetchedDimensions.some((entry) => entry.width || entry.height);
|
||||
mediaDimension = hasAnyDimensions ? fetchedDimensions[0] : undefined;
|
||||
mediaDimensions = hasAnyDimensions ? fetchedDimensions : undefined;
|
||||
}
|
||||
|
||||
if (mediaPaths.length > 1) {
|
||||
if (mediaTypes && mediaTypes.length > 1) {
|
||||
placeholder = formatAttachmentSummaryPlaceholder(mediaTypes);
|
||||
} else if (sticker) {
|
||||
placeholder = "<media:sticker>";
|
||||
} else {
|
||||
const kind = kindFromMime(mediaType ?? undefined);
|
||||
const kind = kindFromMime(mediaType ?? allAttachments[0]?.contentType ?? undefined);
|
||||
if (kind) {
|
||||
placeholder = `<media:${kind}>`;
|
||||
} else if (attachments.length) {
|
||||
} else if (allAttachments.length > 0) {
|
||||
placeholder = "<media:attachment>";
|
||||
} else if (Array.isArray(dataMessage.contacts) && dataMessage.contacts.length > 0) {
|
||||
placeholder = "<media:contact>";
|
||||
} else if (pollCreate) {
|
||||
placeholder = `[Poll] ${pollCreate.question?.trim() || "Untitled"}`;
|
||||
} else if (pollVote) {
|
||||
placeholder = "[Poll vote]";
|
||||
} else if (pollTerminate) {
|
||||
placeholder = "[Poll closed]";
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = messageText || placeholder || dataMessage.quote?.text?.trim() || "";
|
||||
const bodyText = messageText || placeholder || quoteText;
|
||||
if (!bodyText) {
|
||||
return;
|
||||
}
|
||||
@ -785,13 +1200,31 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
||||
groupName,
|
||||
isGroup,
|
||||
bodyText,
|
||||
commandBody: messageText,
|
||||
commandBody: bodyText,
|
||||
bodyTextPlain: messageTextPlain,
|
||||
timestamp: envelope.timestamp ?? undefined,
|
||||
messageId,
|
||||
editTargetTimestamp,
|
||||
isEdit: isEditMessage,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
mediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
mediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
mediaCaption,
|
||||
mediaPaths,
|
||||
mediaTypes,
|
||||
mediaCaptions,
|
||||
mediaDimension,
|
||||
mediaDimensions,
|
||||
untrustedContext:
|
||||
stickerContext.length > 0 ||
|
||||
linkPreviewContext.length > 0 ||
|
||||
contactContext.length > 0 ||
|
||||
pollContext.length > 0
|
||||
? [...stickerContext, ...linkPreviewContext, ...contactContext, ...pollContext]
|
||||
: undefined,
|
||||
replyToId: quoteReplyId,
|
||||
replyToBody: quoteText || undefined,
|
||||
replyToSender: quoteReplySender,
|
||||
replyToIsQuote: quote ? true : undefined,
|
||||
commandAuthorized,
|
||||
wasMentioned: effectiveWasMentioned,
|
||||
});
|
||||
|
||||
@ -15,7 +15,10 @@ export type SignalEnvelope = {
|
||||
sourceName?: string | null;
|
||||
timestamp?: number | null;
|
||||
dataMessage?: SignalDataMessage | null;
|
||||
editMessage?: { dataMessage?: SignalDataMessage | null } | null;
|
||||
editMessage?: {
|
||||
targetSentTimestamp?: number | null;
|
||||
dataMessage?: SignalDataMessage | null;
|
||||
} | null;
|
||||
syncMessage?: unknown;
|
||||
reactionMessage?: SignalReactionMessage | null;
|
||||
};
|
||||
@ -32,12 +35,26 @@ export type SignalDataMessage = {
|
||||
timestamp?: number;
|
||||
message?: string | null;
|
||||
attachments?: Array<SignalAttachment>;
|
||||
sticker?: SignalSticker | null;
|
||||
previews?: Array<SignalLinkPreview> | null;
|
||||
textStyles?: Array<SignalTextStyleRange> | null;
|
||||
mentions?: Array<SignalMention> | null;
|
||||
contacts?: Array<SignalSharedContact> | null;
|
||||
pollCreate?: SignalPollCreate | null;
|
||||
pollVote?: SignalPollVote | null;
|
||||
pollTerminate?: SignalPollTerminate | null;
|
||||
groupInfo?: {
|
||||
groupId?: string | null;
|
||||
groupName?: string | null;
|
||||
} | null;
|
||||
quote?: { text?: string | null } | null;
|
||||
quote?: {
|
||||
id?: number | string | null;
|
||||
timestamp?: number | null;
|
||||
text?: string | null;
|
||||
authorUuid?: string | null;
|
||||
authorNumber?: string | null;
|
||||
author?: string | null;
|
||||
} | null;
|
||||
reaction?: SignalReactionMessage | null;
|
||||
};
|
||||
|
||||
@ -57,7 +74,54 @@ export type SignalAttachment = {
|
||||
id?: string | null;
|
||||
contentType?: string | null;
|
||||
filename?: string | null;
|
||||
caption?: string | null;
|
||||
size?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
};
|
||||
|
||||
export type SignalSticker = {
|
||||
packId?: string | number | null;
|
||||
stickerId?: string | number | null;
|
||||
attachment?: SignalAttachment | null;
|
||||
};
|
||||
|
||||
export type SignalLinkPreview = {
|
||||
url?: string | null;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
image?: SignalAttachment | null;
|
||||
};
|
||||
|
||||
export type SignalTextStyleRange = {
|
||||
style?: string | null;
|
||||
start?: number | null;
|
||||
length?: number | null;
|
||||
};
|
||||
|
||||
export type SignalSharedContact = {
|
||||
name?: { display?: string | null; given?: string | null; family?: string | null } | null;
|
||||
phone?: Array<{ value?: string | null; type?: string | null }> | null;
|
||||
email?: Array<{ value?: string | null; type?: string | null }> | null;
|
||||
organization?: string | null;
|
||||
};
|
||||
|
||||
export type SignalPollCreate = {
|
||||
question?: string | null;
|
||||
allowMultiple?: boolean | null;
|
||||
options?: string[] | null;
|
||||
};
|
||||
|
||||
export type SignalPollVote = {
|
||||
authorNumber?: string | null;
|
||||
authorUuid?: string | null;
|
||||
targetSentTimestamp?: number | null;
|
||||
optionIndexes?: number[] | null;
|
||||
voteCount?: number | null;
|
||||
};
|
||||
|
||||
export type SignalPollTerminate = {
|
||||
targetSentTimestamp?: number | null;
|
||||
};
|
||||
|
||||
export type SignalReactionTarget = {
|
||||
@ -92,6 +156,8 @@ export type SignalEventHandlerDeps = {
|
||||
ignoreAttachments: boolean;
|
||||
sendReadReceipts: boolean;
|
||||
readReceiptsViaDaemon: boolean;
|
||||
injectLinkPreviews?: boolean;
|
||||
preserveTextStyles?: boolean;
|
||||
fetchAttachment: (params: {
|
||||
baseUrl: string;
|
||||
account?: string;
|
||||
|
||||
@ -25,12 +25,27 @@ function clampBounds(start: number, length: number, textLength: number) {
|
||||
return { start: safeStart, end: safeEnd };
|
||||
}
|
||||
|
||||
export function renderSignalMentions(message: string, mentions?: SignalMention[] | null) {
|
||||
export interface MentionRenderResult {
|
||||
text: string;
|
||||
/**
|
||||
* Map from original mention end offset to the shift introduced by mention expansions.
|
||||
* Used to adjust textStyle ranges that reference original message offsets.
|
||||
*/
|
||||
offsetShifts: Map<number, number>;
|
||||
}
|
||||
|
||||
export function renderSignalMentions(
|
||||
message: string,
|
||||
mentions?: SignalMention[] | null,
|
||||
): MentionRenderResult {
|
||||
if (!message || !mentions?.length) {
|
||||
return message;
|
||||
return { text: message, offsetShifts: new Map() };
|
||||
}
|
||||
|
||||
let normalized = message;
|
||||
const offsetShifts = new Map<number, number>();
|
||||
// Process mentions in reverse order (sorted by descending start position)
|
||||
// to avoid having to recalculate positions as we insert text
|
||||
const candidates = mentions.filter(isValidMention).toSorted((a, b) => b.start! - a.start!);
|
||||
|
||||
for (const mention of candidates) {
|
||||
@ -49,8 +64,18 @@ export function renderSignalMentions(message: string, mentions?: SignalMention[]
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized = normalized.slice(0, start) + `@${identifier}` + normalized.slice(end);
|
||||
const replacement = `@${identifier}`;
|
||||
const originalLength = end - start;
|
||||
const newLength = replacement.length;
|
||||
const shift = newLength - originalLength;
|
||||
|
||||
normalized = normalized.slice(0, start) + replacement + normalized.slice(end);
|
||||
|
||||
// Track shift at the original mention end so offsets inside the mention are not shifted.
|
||||
if (shift !== 0) {
|
||||
offsetShifts.set(end, (offsetShifts.get(end) ?? 0) + shift);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
return { text: normalized, offsetShifts };
|
||||
}
|
||||
|
||||
@ -12,6 +12,27 @@ describe("buildInboundMediaNote", () => {
|
||||
expect(note).toBe("[media attached: /tmp/a.png (image/png) | /tmp/a.png]");
|
||||
});
|
||||
|
||||
it("includes attachment dimensions when available", () => {
|
||||
const note = buildInboundMediaNote({
|
||||
MediaPath: "/tmp/photo.jpg",
|
||||
MediaType: "image/jpeg",
|
||||
MediaDimension: { width: 4000, height: 3000 },
|
||||
});
|
||||
expect(note).toBe("[media attached: /tmp/photo.jpg (image/jpeg, 4000x3000)]");
|
||||
});
|
||||
|
||||
it("includes attachment captions when available", () => {
|
||||
const note = buildInboundMediaNote({
|
||||
MediaPath: "/tmp/photo.jpg",
|
||||
MediaType: "image/jpeg",
|
||||
MediaDimension: { width: 4000, height: 3000 },
|
||||
MediaCaption: "sunset over the bay",
|
||||
});
|
||||
expect(note).toBe(
|
||||
'[media attached: /tmp/photo.jpg (image/jpeg, 4000x3000, "sunset over the bay")]',
|
||||
);
|
||||
});
|
||||
|
||||
it("formats multiple MediaPaths as numbered media notes", () => {
|
||||
const note = buildInboundMediaNote({
|
||||
MediaPaths: ["/tmp/a.png", "/tmp/b.png", "/tmp/c.png"],
|
||||
|
||||
@ -4,6 +4,9 @@ function formatMediaAttachedLine(params: {
|
||||
path: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
caption?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
index?: number;
|
||||
total?: number;
|
||||
}): string {
|
||||
@ -11,7 +14,26 @@ function formatMediaAttachedLine(params: {
|
||||
typeof params.index === "number" && typeof params.total === "number"
|
||||
? `[media attached ${params.index}/${params.total}: `
|
||||
: "[media attached: ";
|
||||
const typePart = params.type?.trim() ? ` (${params.type.trim()})` : "";
|
||||
const details: string[] = [];
|
||||
const type = params.type?.trim();
|
||||
if (type) {
|
||||
details.push(type);
|
||||
}
|
||||
if (
|
||||
typeof params.width === "number" &&
|
||||
Number.isFinite(params.width) &&
|
||||
params.width > 0 &&
|
||||
typeof params.height === "number" &&
|
||||
Number.isFinite(params.height) &&
|
||||
params.height > 0
|
||||
) {
|
||||
details.push(`${Math.round(params.width)}x${Math.round(params.height)}`);
|
||||
}
|
||||
const caption = params.caption?.trim();
|
||||
if (caption) {
|
||||
details.push(JSON.stringify(caption));
|
||||
}
|
||||
const typePart = details.length > 0 ? ` (${details.join(", ")})` : "";
|
||||
const urlRaw = params.url?.trim();
|
||||
const urlPart = urlRaw ? ` | ${urlRaw}` : "";
|
||||
return `${prefix}${params.path}${typePart}${urlPart}]`;
|
||||
@ -92,6 +114,14 @@ export function buildInboundMediaNote(ctx: MsgContext): string | undefined {
|
||||
Array.isArray(ctx.MediaTypes) && ctx.MediaTypes.length === paths.length
|
||||
? ctx.MediaTypes
|
||||
: undefined;
|
||||
const captions =
|
||||
Array.isArray(ctx.MediaCaptions) && ctx.MediaCaptions.length === paths.length
|
||||
? ctx.MediaCaptions
|
||||
: undefined;
|
||||
const dimensions =
|
||||
Array.isArray(ctx.MediaDimensions) && ctx.MediaDimensions.length === paths.length
|
||||
? ctx.MediaDimensions
|
||||
: undefined;
|
||||
const hasTranscript = Boolean(ctx.Transcript?.trim());
|
||||
// Transcript alone does not identify an attachment index; only use it as a fallback
|
||||
// when there is a single attachment to avoid stripping unrelated audio files.
|
||||
@ -101,7 +131,10 @@ export function buildInboundMediaNote(ctx: MsgContext): string | undefined {
|
||||
.map((entry, index) => ({
|
||||
path: entry ?? "",
|
||||
type: types?.[index] ?? ctx.MediaType,
|
||||
caption: captions?.[index] ?? (index === 0 ? ctx.MediaCaption : undefined),
|
||||
url: urls?.[index] ?? ctx.MediaUrl,
|
||||
width: dimensions?.[index]?.width ?? (index === 0 ? ctx.MediaDimension?.width : undefined),
|
||||
height: dimensions?.[index]?.height ?? (index === 0 ? ctx.MediaDimension?.height : undefined),
|
||||
index,
|
||||
}))
|
||||
.filter((entry) => {
|
||||
@ -133,7 +166,10 @@ export function buildInboundMediaNote(ctx: MsgContext): string | undefined {
|
||||
return formatMediaAttachedLine({
|
||||
path: entries[0]?.path ?? "",
|
||||
type: entries[0]?.type,
|
||||
caption: entries[0]?.caption,
|
||||
url: entries[0]?.url,
|
||||
width: entries[0]?.width,
|
||||
height: entries[0]?.height,
|
||||
});
|
||||
}
|
||||
|
||||
@ -146,7 +182,10 @@ export function buildInboundMediaNote(ctx: MsgContext): string | undefined {
|
||||
index: idx + 1,
|
||||
total: count,
|
||||
type: entry.type,
|
||||
caption: entry.caption,
|
||||
url: entry.url,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -54,6 +54,8 @@ export type MsgContext = {
|
||||
MessageSids?: string[];
|
||||
MessageSidFirst?: string;
|
||||
MessageSidLast?: string;
|
||||
/** Provider-specific timestamp/id targeted by an edit message. */
|
||||
EditTargetTimestamp?: number;
|
||||
ReplyToId?: string;
|
||||
/**
|
||||
* Root message id for thread reconstruction (used by Feishu for root_id).
|
||||
@ -89,10 +91,20 @@ export type MsgContext = {
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
MediaCaption?: string;
|
||||
MediaDir?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
MediaCaptions?: string[];
|
||||
MediaDimension?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
MediaDimensions?: Array<{
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>;
|
||||
/** Telegram sticker metadata (emoji, set name, file IDs, cached description). */
|
||||
Sticker?: StickerMetadata;
|
||||
/** True when current-turn sticker media is present in MediaPaths (false for cached-description path). */
|
||||
|
||||
@ -52,6 +52,10 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & {
|
||||
* - "extensive": Agent can react liberally
|
||||
*/
|
||||
reactionLevel?: SignalReactionLevel;
|
||||
/** Extract link preview metadata into UntrustedContext (default: true). */
|
||||
injectLinkPreviews?: boolean;
|
||||
/** Apply Signal text styles (bold/italic/monospace/strikethrough/spoiler) to message text (default: true). */
|
||||
preserveTextStyles?: boolean;
|
||||
};
|
||||
|
||||
export type SignalConfig = {
|
||||
|
||||
@ -1018,6 +1018,8 @@ export const SignalAccountSchemaBase = z
|
||||
ignoreAttachments: z.boolean().optional(),
|
||||
ignoreStories: z.boolean().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
injectLinkPreviews: z.boolean().optional(),
|
||||
preserveTextStyles: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
defaultTo: z.string().optional(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user