mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(signal): forward all inbound attachments from #39212 (thanks @joeykrug)
Co-authored-by: Joey Krug <joeykrug@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user