Merge 83fecf2f66f7b663859358885a84027f31fa80e7 into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
Hudson 2026-03-21 04:32:09 +01:00 committed by GitHub
commit 5872769b15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1047 additions and 50 deletions

View File

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

View File

@ -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`).

View File

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

View File

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

View File

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

View File

@ -22,6 +22,8 @@ export function createBaseSignalEventHandlerDeps(
ignoreAttachments: true,
sendReadReceipts: false,
readReceiptsViaDaemon: false,
injectLinkPreviews: true,
preserveTextStyles: true,
fetchAttachment: async () => null,
deliverReplies: async () => {},
resolveSignalReactionTargets: () => [],

View File

@ -51,13 +51,213 @@ import { normalizeSignalMessagingTarget } from "../runtime-api.js";
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" : ""}`;
@ -106,12 +306,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;
};
@ -205,13 +417,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,
@ -350,9 +572,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) => {
@ -368,16 +598,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) => {
@ -508,6 +763,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)
@ -517,12 +778,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({
@ -603,7 +960,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: [
@ -632,7 +989,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({
@ -661,9 +1018,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.
@ -671,13 +1042,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>";
})();
@ -700,12 +1071,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;
}
@ -718,34 +1099,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;
}
@ -787,13 +1202,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,
});

View File

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

View File

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

View File

@ -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"],

View File

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

View File

@ -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). */

View File

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

View File

@ -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(),