From 83fecf2f66f7b663859358885a84027f31fa80e7 Mon Sep 17 00:00:00 2001 From: Hudson <258693705+hudson-rivera@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:33:22 -0400 Subject: [PATCH] feat(signal): enhanced inbound handling, unsend, polls, and poll voting Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/channels/signal.md | 90 ++++ docs/gateway/configuration-reference.md | 7 + extensions/signal/src/monitor.ts | 4 + .../event-handler.inbound-context.test.ts | 290 ++++++++++ .../event-handler.mention-gating.test.ts | 18 +- .../src/monitor/event-handler.test-harness.ts | 2 + .../signal/src/monitor/event-handler.ts | 503 ++++++++++++++++-- .../signal/src/monitor/event-handler.types.ts | 70 ++- extensions/signal/src/monitor/mentions.ts | 33 +- src/auto-reply/media-note.test.ts | 21 + src/auto-reply/media-note.ts | 41 +- src/auto-reply/templating.ts | 12 + src/config/types.signal.ts | 4 + src/config/zod-schema.providers-core.ts | 2 + 14 files changed, 1047 insertions(+), 50 deletions(-) diff --git a/docs/channels/signal.md b/docs/channels/signal.md index fb5747dc417..8e4a88f9e7f 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -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: ` +- `Signal stickerId: ` + +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: - <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: diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 11ea717513a..4e7ee77bdd1 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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`). diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index b0e601fc01e..c62490735d1 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -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, diff --git a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts index 3aafda7fe3d..f1ad83771db 100644 --- a/extensions/signal/src/monitor/event-handler.inbound-context.test.ts +++ b/extensions/signal/src/monitor/event-handler.inbound-context.test.ts @@ -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"); + }); +}); diff --git a/extensions/signal/src/monitor/event-handler.mention-gating.test.ts b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts index ffcdb5baba6..f0134eac98e 100644 --- a/extensions/signal/src/monitor/event-handler.mention-gating.test.ts +++ b/extensions/signal/src/monitor/event-handler.mention-gating.test.ts @@ -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"); }); }); diff --git a/extensions/signal/src/monitor/event-handler.test-harness.ts b/extensions/signal/src/monitor/event-handler.test-harness.ts index 1c81dd08179..ac2afef6b80 100644 --- a/extensions/signal/src/monitor/event-handler.test-harness.ts +++ b/extensions/signal/src/monitor/event-handler.test-harness.ts @@ -22,6 +22,8 @@ export function createBaseSignalEventHandlerDeps( ignoreAttachments: true, sendReadReceipts: false, readReceiptsViaDaemon: false, + injectLinkPreviews: true, + preserveTextStyles: true, fetchAttachment: async () => null, deliverReplies: async () => {}, resolveSignalReactionTargets: () => [], diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 23eb676ae82..dd1fe14c8f1 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -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, }); diff --git a/extensions/signal/src/monitor/event-handler.types.ts b/extensions/signal/src/monitor/event-handler.types.ts index 82a96af73cc..ee58bfd5e63 100644 --- a/extensions/signal/src/monitor/event-handler.types.ts +++ b/extensions/signal/src/monitor/event-handler.types.ts @@ -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; diff --git a/extensions/signal/src/monitor/mentions.ts b/extensions/signal/src/monitor/mentions.ts index 04adec9c96e..d707ff2ce04 100644 --- a/extensions/signal/src/monitor/mentions.ts +++ b/extensions/signal/src/monitor/mentions.ts @@ -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 }; } diff --git a/src/auto-reply/media-note.test.ts b/src/auto-reply/media-note.test.ts index 66fb41fbf2d..a627edc61bc 100644 --- a/src/auto-reply/media-note.test.ts +++ b/src/auto-reply/media-note.test.ts @@ -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"], diff --git a/src/auto-reply/media-note.ts b/src/auto-reply/media-note.ts index 7835988f56e..8cf6dc530f9 100644 --- a/src/auto-reply/media-note.ts +++ b/src/auto-reply/media-note.ts @@ -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, }), ); } diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 4485e2c22ee..3ece5480cdf 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -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). */ diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index bd33a64cf51..db7dbd8e501 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -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 = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index e65030d8f38..0cb35b16977 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(),