fix(signal): forward all inbound attachments from #39212 (thanks @joeykrug)

Co-authored-by: Joey Krug <joeykrug@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-07 23:35:55 +00:00
parent 939b18475d
commit adf4eb487b
4 changed files with 136 additions and 22 deletions

View File

@@ -304,6 +304,7 @@ Docs: https://docs.openclaw.ai
- Control UI/auth error reporting: map generic browser `Fetch failed` websocket close errors back to actionable gateway auth messages (`gateway token mismatch`, `authentication failed`, `retry later`) so dashboard disconnects stop hiding credential problems. Landed from contributor PR #28608 by @KimGLee. Thanks @KimGLee.
- Media/mime unknown-kind handling: return `undefined` (not `"unknown"`) for missing/unrecognized MIME kinds and use document-size fallback caps for unknown remote media, preventing phantom `<media:unknown>` Signal events from being treated as real messages. (#39199) Thanks @nicolasgrasset.
- Nodes/system.run allow-always persistence: honor shell comment semantics during allowlist analysis so `#`-tailed payloads that never execute are not persisted as trusted follow-up commands. Thanks @tdjackey for reporting.
- Signal/inbound attachment fan-in: forward all successfully fetched inbound attachments through `MediaPaths`/`MediaUrls`/`MediaTypes` (instead of only the first), and improve multi-attachment placeholder summaries in mention-gated pending history. (#39212) Thanks @joeykrug.
## 2026.3.2

View File

@@ -173,6 +173,39 @@ describe("signal createSignalEventHandler inbound contract", () => {
expect(capture.ctx?.CommandAuthorized).toBe(false);
});
it("forwards all fetched attachments via MediaPaths/MediaTypes", async () => {
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: {
messages: { inbound: { debounceMs: 0 } },
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
},
ignoreAttachments: false,
fetchAttachment: async ({ attachment }) => ({
path: `/tmp/${String(attachment.id)}.dat`,
contentType: attachment.id === "a1" ? "image/jpeg" : undefined,
}),
historyLimit: 0,
}),
);
await handler(
createSignalReceiveEvent({
dataMessage: {
message: "",
attachments: [{ id: "a1", contentType: "image/jpeg" }, { id: "a2" }],
},
}),
);
expect(capture.ctx).toBeTruthy();
expect(capture.ctx?.MediaPath).toBe("/tmp/a1.dat");
expect(capture.ctx?.MediaType).toBe("image/jpeg");
expect(capture.ctx?.MediaPaths).toEqual(["/tmp/a1.dat", "/tmp/a2.dat"]);
expect(capture.ctx?.MediaUrls).toEqual(["/tmp/a1.dat", "/tmp/a2.dat"]);
expect(capture.ctx?.MediaTypes).toEqual(["image/jpeg", "application/octet-stream"]);
});
it("drops own UUID inbound messages when only accountUuid is configured", async () => {
const ownUuid = "123e4567-e89b-12d3-a456-426614174000";
const handler = createSignalEventHandler(

View File

@@ -171,6 +171,34 @@ describe("signal mention gating", () => {
expect(entries[0].body).toBe("<media:audio>");
});
it("summarizes multiple skipped attachments with stable file count wording", async () => {
capturedCtx = undefined;
const groupHistories = new Map();
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
cfg: createSignalConfig({ requireMention: true }),
historyLimit: 5,
groupHistories,
ignoreAttachments: false,
fetchAttachment: async ({ attachment }) => ({
path: `/tmp/${String(attachment.id)}.bin`,
}),
}),
);
await handler(
makeGroupEvent({
message: "",
attachments: [{ id: "a1" }, { id: "a2" }],
}),
);
expect(capturedCtx).toBeUndefined();
const entries = groupHistories.get("g1");
expect(entries).toHaveLength(1);
expect(entries[0].body).toBe("[2 files attached]");
});
it("records quote text in pending history for skipped quote-only group messages", async () => {
await expectSkippedGroupHistory({ message: "", quoteText: "quoted context" }, "quoted context");
});

View File

@@ -56,6 +56,26 @@ import type {
SignalReceivePayload,
} from "./event-handler.types.js";
import { renderSignalMentions } from "./mentions.js";
function formatAttachmentKindCount(kind: string, count: number): string {
if (kind === "attachment") {
return `${count} file${count > 1 ? "s" : ""}`;
}
return `${count} ${kind}${count > 1 ? "s" : ""}`;
}
function formatAttachmentSummaryPlaceholder(contentTypes: Array<string | undefined>): string {
const kindCounts = new Map<string, number>();
for (const contentType of contentTypes) {
const kind = kindFromMime(contentType) ?? "attachment";
kindCounts.set(kind, (kindCounts.get(kind) ?? 0) + 1);
}
const parts = [...kindCounts.entries()].map(([kind, count]) =>
formatAttachmentKindCount(kind, count),
);
return `[${parts.join(" + ")} attached]`;
}
export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
type SignalInboundEntry = {
senderName: string;
@@ -71,6 +91,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
messageId?: string;
mediaPath?: string;
mediaType?: string;
mediaPaths?: string[];
mediaTypes?: string[];
commandAuthorized: boolean;
wasMentioned?: boolean;
};
@@ -170,6 +192,9 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
MediaPath: entry.mediaPath,
MediaType: entry.mediaType,
MediaUrl: entry.mediaPath,
MediaPaths: entry.mediaPaths,
MediaUrls: entry.mediaPaths,
MediaTypes: entry.mediaTypes,
WasMentioned: entry.isGroup ? entry.wasMentioned === true : undefined,
CommandAuthorized: entry.commandAuthorized,
OriginatingChannel: "signal" as const,
@@ -311,7 +336,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
return shouldDebounceTextInbound({
text: entry.bodyText,
cfg: deps.cfg,
hasMedia: Boolean(entry.mediaPath || entry.mediaType),
hasMedia: Boolean(entry.mediaPath || entry.mediaType || entry.mediaPaths?.length),
});
},
onFlush: async (entries) => {
@@ -335,6 +360,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
bodyText: combinedText,
mediaPath: undefined,
mediaType: undefined,
mediaPaths: undefined,
mediaTypes: undefined,
});
},
onError: (err) => {
@@ -632,6 +659,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
if (deps.ignoreAttachments) {
return "<media:attachment>";
}
const attachmentTypes = (dataMessage.attachments ?? []).map((attachment) =>
typeof attachment?.contentType === "string" ? attachment.contentType : undefined,
);
if (attachmentTypes.length > 1) {
return formatAttachmentSummaryPlaceholder(attachmentTypes);
}
const firstContentType = dataMessage.attachments?.[0]?.contentType;
const pendingKind = kindFromMime(firstContentType ?? undefined);
return pendingKind ? `<media:${pendingKind}>` : "<media:attachment>";
@@ -655,32 +688,49 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
let mediaPath: string | undefined;
let mediaType: string | undefined;
const mediaPaths: string[] = [];
const mediaTypes: string[] = [];
let placeholder = "";
const firstAttachment = dataMessage.attachments?.[0];
if (firstAttachment?.id && !deps.ignoreAttachments) {
try {
const fetched = await deps.fetchAttachment({
baseUrl: deps.baseUrl,
account: deps.account,
attachment: firstAttachment,
sender: senderRecipient,
groupId,
maxBytes: deps.mediaMaxBytes,
});
if (fetched) {
mediaPath = fetched.path;
mediaType = fetched.contentType ?? firstAttachment.contentType ?? undefined;
const attachments = dataMessage.attachments ?? [];
if (!deps.ignoreAttachments) {
for (const attachment of attachments) {
if (!attachment?.id) {
continue;
}
try {
const fetched = await deps.fetchAttachment({
baseUrl: deps.baseUrl,
account: deps.account,
attachment,
sender: senderRecipient,
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;
}
}
} catch (err) {
deps.runtime.error?.(danger(`attachment fetch failed: ${String(err)}`));
}
} catch (err) {
deps.runtime.error?.(danger(`attachment fetch failed: ${String(err)}`));
}
}
const kind = kindFromMime(mediaType ?? undefined);
if (kind) {
placeholder = `<media:${kind}>`;
} else if (dataMessage.attachments?.length) {
placeholder = "<media:attachment>";
if (mediaPaths.length > 1) {
placeholder = formatAttachmentSummaryPlaceholder(mediaTypes);
} else {
const kind = kindFromMime(mediaType ?? undefined);
if (kind) {
placeholder = `<media:${kind}>`;
} else if (attachments.length) {
placeholder = "<media:attachment>";
}
}
const bodyText = messageText || placeholder || dataMessage.quote?.text?.trim() || "";
@@ -730,6 +780,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
messageId,
mediaPath,
mediaType,
mediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
mediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
commandAuthorized,
wasMentioned: effectiveWasMentioned,
});