From f35fcb89b435d89d08b50bf14086cc82b7ae9f5b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 12 Mar 2026 19:40:30 +0000 Subject: [PATCH] Matrix: harden media handling and summaries --- .../matrix/src/matrix/actions/summary.test.ts | 87 +++++++ .../matrix/src/matrix/actions/summary.ts | 12 +- extensions/matrix/src/matrix/actions/types.ts | 9 + extensions/matrix/src/matrix/media-text.ts | 147 +++++++++++ .../monitor/handler.media-failure.test.ts | 239 ++++++++++++++++++ .../monitor/handler.thread-root-media.test.ts | 159 ++++++++++++ .../matrix/src/matrix/monitor/handler.ts | 45 +++- .../src/matrix/monitor/thread-context.test.ts | 15 ++ .../src/matrix/monitor/thread-context.ts | 20 +- 9 files changed, 728 insertions(+), 5 deletions(-) create mode 100644 extensions/matrix/src/matrix/actions/summary.test.ts create mode 100644 extensions/matrix/src/matrix/media-text.ts create mode 100644 extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts create mode 100644 extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts diff --git a/extensions/matrix/src/matrix/actions/summary.test.ts b/extensions/matrix/src/matrix/actions/summary.test.ts new file mode 100644 index 00000000000..dcffd9757dd --- /dev/null +++ b/extensions/matrix/src/matrix/actions/summary.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { summarizeMatrixRawEvent } from "./summary.js"; + +describe("summarizeMatrixRawEvent", () => { + it("replaces bare media filenames with a media marker", () => { + const summary = summarizeMatrixRawEvent({ + event_id: "$image", + sender: "@gum:matrix.example.org", + type: "m.room.message", + origin_server_ts: 123, + content: { + msgtype: "m.image", + body: "photo.jpg", + }, + }); + + expect(summary).toMatchObject({ + eventId: "$image", + msgtype: "m.image", + attachment: { + kind: "image", + filename: "photo.jpg", + }, + }); + expect(summary.body).toBeUndefined(); + }); + + it("preserves captions while marking media summaries", () => { + const summary = summarizeMatrixRawEvent({ + event_id: "$image", + sender: "@gum:matrix.example.org", + type: "m.room.message", + origin_server_ts: 123, + content: { + msgtype: "m.image", + body: "can you see this?", + filename: "photo.jpg", + }, + }); + + expect(summary).toMatchObject({ + body: "can you see this?", + attachment: { + kind: "image", + caption: "can you see this?", + filename: "photo.jpg", + }, + }); + }); + + it("does not treat a sentence ending in a file extension as a bare filename", () => { + const summary = summarizeMatrixRawEvent({ + event_id: "$image", + sender: "@gum:matrix.example.org", + type: "m.room.message", + origin_server_ts: 123, + content: { + msgtype: "m.image", + body: "see image.png", + }, + }); + + expect(summary).toMatchObject({ + body: "see image.png", + attachment: { + kind: "image", + caption: "see image.png", + }, + }); + }); + + it("leaves text messages unchanged", () => { + const summary = summarizeMatrixRawEvent({ + event_id: "$text", + sender: "@gum:matrix.example.org", + type: "m.room.message", + origin_server_ts: 123, + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + expect(summary.body).toBe("hello"); + expect(summary.attachment).toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts index b0168d4f2c6..69a3a76715d 100644 --- a/extensions/matrix/src/matrix/actions/summary.ts +++ b/extensions/matrix/src/matrix/actions/summary.ts @@ -1,3 +1,4 @@ +import { resolveMatrixMessageAttachment, resolveMatrixMessageBody } from "../media-text.js"; import { fetchMatrixPollMessageSummary } from "../poll-summary.js"; import type { MatrixClient } from "../sdk.js"; import { @@ -31,8 +32,17 @@ export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSum return { eventId: event.event_id, sender: event.sender, - body: content.body, + body: resolveMatrixMessageBody({ + body: content.body, + filename: content.filename, + msgtype: content.msgtype, + }), msgtype: content.msgtype, + attachment: resolveMatrixMessageAttachment({ + body: content.body, + filename: content.filename, + msgtype: content.msgtype, + }), timestamp: event.origin_server_ts, relatesTo, }; diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts index e950c6f800b..8cc79959281 100644 --- a/extensions/matrix/src/matrix/actions/types.ts +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -59,6 +59,7 @@ export type MatrixMessageSummary = { sender?: string; body?: string; msgtype?: string; + attachment?: MatrixMessageAttachmentSummary; timestamp?: number; relatesTo?: { relType?: string; @@ -67,6 +68,14 @@ export type MatrixMessageSummary = { }; }; +export type MatrixMessageAttachmentKind = "audio" | "file" | "image" | "sticker" | "video"; + +export type MatrixMessageAttachmentSummary = { + kind: MatrixMessageAttachmentKind; + caption?: string; + filename?: string; +}; + export type MatrixActionClient = { client: MatrixClient; stopOnDone: boolean; diff --git a/extensions/matrix/src/matrix/media-text.ts b/extensions/matrix/src/matrix/media-text.ts new file mode 100644 index 00000000000..7ad195bf0fe --- /dev/null +++ b/extensions/matrix/src/matrix/media-text.ts @@ -0,0 +1,147 @@ +import path from "node:path"; +import type { + MatrixMessageAttachmentKind, + MatrixMessageAttachmentSummary, + MatrixMessageSummary, +} from "./actions/types.js"; + +const MATRIX_MEDIA_KINDS: Record = { + "m.audio": "audio", + "m.file": "file", + "m.image": "image", + "m.sticker": "sticker", + "m.video": "video", +}; + +function resolveMatrixMediaKind(msgtype: string | undefined): MatrixMessageAttachmentKind | null { + return MATRIX_MEDIA_KINDS[msgtype ?? ""] ?? null; +} + +function resolveMatrixMediaLabel( + kind: MatrixMessageAttachmentKind | undefined, + fallback = "media", +): string { + return `${kind ?? fallback} attachment`; +} + +function formatMatrixAttachmentMarker(params: { + kind?: MatrixMessageAttachmentKind; + unavailable?: boolean; +}): string { + const label = resolveMatrixMediaLabel(params.kind); + return params.unavailable ? `[matrix ${label} unavailable]` : `[matrix ${label}]`; +} + +export function isLikelyBareFilename(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) { + return false; + } + if (path.basename(trimmed) !== trimmed) { + return false; + } + return path.extname(trimmed).length > 1; +} + +function resolveCaptionOrFilename(params: { body?: string; filename?: string }): { + caption?: string; + filename?: string; +} { + const body = params.body?.trim() ?? ""; + const filename = params.filename?.trim() ?? ""; + if (filename) { + if (!body || body === filename) { + return { filename }; + } + return { caption: body, filename }; + } + if (!body) { + return {}; + } + if (isLikelyBareFilename(body)) { + return { filename: body }; + } + return { caption: body }; +} + +export function resolveMatrixMessageAttachment(params: { + body?: string; + filename?: string; + msgtype?: string; +}): MatrixMessageAttachmentSummary | undefined { + const kind = resolveMatrixMediaKind(params.msgtype); + if (!kind) { + return undefined; + } + const resolved = resolveCaptionOrFilename(params); + return { + kind, + caption: resolved.caption, + filename: resolved.filename, + }; +} + +export function resolveMatrixMessageBody(params: { + body?: string; + filename?: string; + msgtype?: string; +}): string | undefined { + const attachment = resolveMatrixMessageAttachment(params); + if (!attachment) { + const body = params.body?.trim() ?? ""; + return body || undefined; + } + return attachment.caption; +} + +export function formatMatrixAttachmentText(params: { + attachment?: MatrixMessageAttachmentSummary; + unavailable?: boolean; +}): string | undefined { + if (!params.attachment) { + return undefined; + } + return formatMatrixAttachmentMarker({ + kind: params.attachment.kind, + unavailable: params.unavailable, + }); +} + +export function formatMatrixMessageText(params: { + body?: string; + attachment?: MatrixMessageAttachmentSummary; + unavailable?: boolean; +}): string | undefined { + const body = params.body?.trim() ?? ""; + const marker = formatMatrixAttachmentText({ + attachment: params.attachment, + unavailable: params.unavailable, + }); + if (!marker) { + return body || undefined; + } + if (!body) { + return marker; + } + return `${body}\n\n${marker}`; +} + +export function formatMatrixMessageSummaryText( + summary: Pick, +): string | undefined { + return formatMatrixMessageText(summary); +} + +export function formatMatrixMediaUnavailableText(params: { + body?: string; + filename?: string; + msgtype?: string; +}): string { + return ( + formatMatrixMessageText({ + body: resolveMatrixMessageBody(params), + attachment: resolveMatrixMessageAttachment(params), + unavailable: true, + }) ?? "" + ); +} diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts new file mode 100644 index 00000000000..e1fc7e969ca --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -0,0 +1,239 @@ +import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../../runtime.js"; +import type { MatrixClient } from "../sdk.js"; +import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; + +const { downloadMatrixMediaMock } = vi.hoisted(() => ({ + downloadMatrixMediaMock: vi.fn(), +})); + +vi.mock("./media.js", () => ({ + downloadMatrixMedia: (...args: unknown[]) => downloadMatrixMediaMock(...args), +})); + +import { createMatrixRoomMessageHandler } from "./handler.js"; + +function createHandlerHarness() { + const recordInboundSession = vi.fn().mockResolvedValue(undefined); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as RuntimeLogger; + const runtime = { + error: vi.fn(), + } as unknown as RuntimeEnv; + const core = { + channel: { + pairing: { + readAllowFromStore: vi.fn().mockResolvedValue([]), + upsertPairingRequest: vi.fn().mockResolvedValue(undefined), + buildPairingReply: vi.fn().mockReturnValue("pairing"), + }, + routing: { + resolveAgentRoute: vi.fn().mockReturnValue({ + agentId: "main", + accountId: undefined, + sessionKey: "agent:main:matrix:channel:!room:example.org", + mainSessionKey: "agent:main:main", + }), + }, + session: { + resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"), + readSessionUpdatedAt: vi.fn().mockReturnValue(123), + recordInboundSession, + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}), + formatAgentEnvelope: vi.fn().mockImplementation((params: { body: string }) => params.body), + finalizeInboundContext: vi.fn().mockImplementation((ctx: Record) => ctx), + createReplyDispatcherWithTyping: vi.fn().mockReturnValue({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + }), + resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined), + dispatchReplyFromConfig: vi + .fn() + .mockResolvedValue({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }), + }, + commands: { + shouldHandleTextCommands: vi.fn().mockReturnValue(true), + }, + text: { + hasControlCommand: vi.fn().mockReturnValue(false), + resolveMarkdownTableMode: vi.fn().mockReturnValue("code"), + }, + reactions: { + shouldAckReaction: vi.fn().mockReturnValue(false), + }, + }, + system: { + enqueueSystemEvent: vi.fn(), + }, + } as unknown as PluginRuntime; + + const client = { + getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"), + } as unknown as MatrixClient; + + const handler = createMatrixRoomMessageHandler({ + client, + core, + cfg: {}, + accountId: "ops", + runtime, + logger, + logVerboseMessage: vi.fn(), + allowFrom: [], + groupAllowFrom: [], + roomsConfig: undefined, + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "first", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "open", + textLimit: 4000, + mediaMaxBytes: 5 * 1024 * 1024, + startupMs: Date.now() - 120_000, + startupGraceMs: 60_000, + directTracker: { + isDirectMessage: vi.fn().mockResolvedValue(true), + }, + getRoomInfo: vi.fn().mockResolvedValue({ + name: "Media Room", + canonicalAlias: "#media:example.org", + altAliases: [], + }), + getMemberDisplayName: vi.fn().mockResolvedValue("Gum"), + needsRoomAliasesForConfig: false, + }); + + return { handler, recordInboundSession, logger, runtime }; +} + +function createImageEvent(content: Record): MatrixRawEvent { + return { + type: EventType.RoomMessage, + event_id: "$event1", + sender: "@gum:matrix.example.org", + origin_server_ts: Date.now(), + content: { + ...content, + "m.mentions": { user_ids: ["@bot:matrix.example.org"] }, + }, + } as MatrixRawEvent; +} + +describe("createMatrixRoomMessageHandler media failures", () => { + beforeEach(() => { + downloadMatrixMediaMock.mockReset(); + setMatrixRuntime({ + channel: { + mentions: { + matchesMentionPatterns: (text: string, patterns: RegExp[]) => + patterns.some((pattern) => pattern.test(text)), + }, + media: { + saveMediaBuffer: vi.fn(), + }, + }, + config: { + loadConfig: () => ({}), + }, + state: { + resolveStateDir: () => "/tmp", + }, + } as unknown as PluginRuntime); + }); + + it("replaces bare image filenames with an unavailable marker when unencrypted download fails", async () => { + downloadMatrixMediaMock.mockRejectedValue(new Error("download failed")); + const { handler, recordInboundSession, logger, runtime } = createHandlerHarness(); + + await handler( + "!room:example.org", + createImageEvent({ + msgtype: "m.image", + body: "image.png", + url: "mxc://example/image", + }), + ); + + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + RawBody: "[matrix image attachment unavailable]", + CommandBody: "[matrix image attachment unavailable]", + MediaPath: undefined, + }), + }), + ); + expect(logger.warn).toHaveBeenCalledWith( + "matrix media download failed", + expect.objectContaining({ + eventId: "$event1", + msgtype: "m.image", + encrypted: false, + }), + ); + expect(runtime.error).not.toHaveBeenCalled(); + }); + + it("replaces bare image filenames with an unavailable marker when encrypted download fails", async () => { + downloadMatrixMediaMock.mockRejectedValue(new Error("decrypt failed")); + const { handler, recordInboundSession } = createHandlerHarness(); + + await handler( + "!room:example.org", + createImageEvent({ + msgtype: "m.image", + body: "photo.jpg", + file: { + url: "mxc://example/encrypted", + key: { kty: "oct", key_ops: ["encrypt"], alg: "A256CTR", k: "secret", ext: true }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }, + }), + ); + + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + RawBody: "[matrix image attachment unavailable]", + CommandBody: "[matrix image attachment unavailable]", + MediaPath: undefined, + }), + }), + ); + }); + + it("preserves a real caption while marking the attachment unavailable", async () => { + downloadMatrixMediaMock.mockRejectedValue(new Error("download failed")); + const { handler, recordInboundSession } = createHandlerHarness(); + + await handler( + "!room:example.org", + createImageEvent({ + msgtype: "m.image", + body: "can you see this image?", + filename: "image.png", + url: "mxc://example/image", + }), + ); + + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + RawBody: "can you see this image?\n\n[matrix image attachment unavailable]", + CommandBody: "can you see this image?\n\n[matrix image attachment unavailable]", + }), + }), + ); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts new file mode 100644 index 00000000000..7dfbcebe401 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts @@ -0,0 +1,159 @@ +import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import { describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../../runtime.js"; +import type { MatrixClient } from "../sdk.js"; +import { createMatrixRoomMessageHandler } from "./handler.js"; +import { EventType, type MatrixRawEvent } from "./types.js"; + +describe("createMatrixRoomMessageHandler thread root media", () => { + it("keeps image-only thread roots visible via attachment markers", async () => { + setMatrixRuntime({ + channel: { + mentions: { + matchesMentionPatterns: (text: string, patterns: RegExp[]) => + patterns.some((pattern) => pattern.test(text)), + }, + media: { + saveMediaBuffer: vi.fn(), + }, + }, + config: { + loadConfig: () => ({}), + }, + state: { + resolveStateDir: () => "/tmp", + }, + } as unknown as PluginRuntime); + + const recordInboundSession = vi.fn().mockResolvedValue(undefined); + const formatAgentEnvelope = vi + .fn() + .mockImplementation((params: { body: string }) => params.body); + + const core = { + channel: { + pairing: { + readAllowFromStore: vi.fn().mockResolvedValue([]), + upsertPairingRequest: vi.fn().mockResolvedValue(undefined), + buildPairingReply: vi.fn().mockReturnValue("pairing"), + }, + routing: { + resolveAgentRoute: vi.fn().mockReturnValue({ + agentId: "main", + accountId: undefined, + sessionKey: "agent:main:matrix:channel:!room:example.org", + mainSessionKey: "agent:main:main", + }), + }, + session: { + resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"), + readSessionUpdatedAt: vi.fn().mockReturnValue(undefined), + recordInboundSession, + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}), + formatAgentEnvelope, + finalizeInboundContext: vi.fn().mockImplementation((ctx: Record) => ctx), + createReplyDispatcherWithTyping: vi.fn().mockReturnValue({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + }), + resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined), + dispatchReplyFromConfig: vi + .fn() + .mockResolvedValue({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }), + }, + commands: { + shouldHandleTextCommands: vi.fn().mockReturnValue(true), + }, + text: { + hasControlCommand: vi.fn().mockReturnValue(false), + resolveMarkdownTableMode: vi.fn().mockReturnValue("code"), + }, + reactions: { + shouldAckReaction: vi.fn().mockReturnValue(false), + }, + }, + system: { + enqueueSystemEvent: vi.fn(), + }, + } as unknown as PluginRuntime; + + const client = { + getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"), + getEvent: vi.fn().mockResolvedValue({ + event_id: "$thread-root", + sender: "@gum:matrix.example.org", + type: "m.room.message", + origin_server_ts: 123, + content: { + msgtype: "m.image", + body: "photo.jpg", + }, + }), + } as unknown as MatrixClient; + + const handler = createMatrixRoomMessageHandler({ + client, + core, + cfg: {}, + accountId: "ops", + runtime: { error: vi.fn() } as unknown as RuntimeEnv, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as RuntimeLogger, + logVerboseMessage: vi.fn(), + allowFrom: [], + groupAllowFrom: [], + roomsConfig: undefined, + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "first", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "open", + textLimit: 4000, + mediaMaxBytes: 5 * 1024 * 1024, + startupMs: Date.now() - 120_000, + startupGraceMs: 60_000, + directTracker: { + isDirectMessage: vi.fn().mockResolvedValue(true), + }, + getRoomInfo: vi.fn().mockResolvedValue({ + name: "Media Room", + canonicalAlias: "#media:example.org", + altAliases: [], + }), + getMemberDisplayName: vi.fn().mockResolvedValue("Gum"), + needsRoomAliasesForConfig: false, + }); + + await handler("!room:example.org", { + type: EventType.RoomMessage, + event_id: "$reply", + sender: "@bu:matrix.example.org", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "replying", + "m.mentions": { user_ids: ["@bot:matrix.example.org"] }, + "m.relates_to": { + rel_type: "m.thread", + event_id: "$thread-root", + }, + }, + } as MatrixRawEvent); + + expect(formatAgentEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining("replying"), + }), + ); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + ThreadStarterBody: expect.stringContaining("[matrix image attachment]"), + }), + }), + ); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 0ea035e84a2..d2ea00b53ac 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -13,6 +13,7 @@ import { type RuntimeLogger, } from "openclaw/plugin-sdk/matrix"; import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; +import { formatMatrixMediaUnavailableText } from "../media-text.js"; import { fetchMatrixPollSnapshot } from "../poll-summary.js"; import { formatPollAsText, @@ -102,6 +103,27 @@ function resolveMatrixMentionPrecheckText(params: { return ""; } +function resolveMatrixInboundBodyText(params: { + rawBody: string; + filename?: string; + mediaPlaceholder?: string; + msgtype?: string; + hadMediaUrl: boolean; + mediaDownloadFailed: boolean; +}): string { + if (params.mediaPlaceholder) { + return params.rawBody || params.mediaPlaceholder; + } + if (!params.mediaDownloadFailed || !params.hadMediaUrl) { + return params.rawBody; + } + return formatMatrixMediaUnavailableText({ + body: params.rawBody, + filename: params.filename, + msgtype: params.msgtype, + }); +} + export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { const { client, @@ -519,6 +541,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam contentType?: string; placeholder: string; } | null = null; + let mediaDownloadFailed = false; const finalContentUrl = "url" in content && typeof content.url === "string" ? content.url : undefined; const finalContentFile = @@ -543,13 +566,31 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam file: finalContentFile, }); } catch (err) { - logVerboseMessage(`matrix: media download failed: ${String(err)}`); + mediaDownloadFailed = true; + const errorText = err instanceof Error ? err.message : String(err); + logVerboseMessage( + `matrix: media download failed room=${roomId} id=${event.event_id ?? "unknown"} type=${content.msgtype} error=${errorText}`, + ); + logger.warn("matrix media download failed", { + roomId, + eventId: event.event_id, + msgtype: content.msgtype, + encrypted: Boolean(finalContentFile), + error: errorText, + }); } } const rawBody = locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : ""); - const bodyText = rawBody || media?.placeholder || ""; + const bodyText = resolveMatrixInboundBodyText({ + rawBody, + filename: typeof content.filename === "string" ? content.filename : undefined, + mediaPlaceholder: media?.placeholder, + msgtype: content.msgtype, + hadMediaUrl: Boolean(finalMediaUrl), + mediaDownloadFailed, + }); if (!bodyText) { return; } diff --git a/extensions/matrix/src/matrix/monitor/thread-context.test.ts b/extensions/matrix/src/matrix/monitor/thread-context.test.ts index f09655bf4c7..2e1dd16c833 100644 --- a/extensions/matrix/src/matrix/monitor/thread-context.test.ts +++ b/extensions/matrix/src/matrix/monitor/thread-context.test.ts @@ -21,6 +21,21 @@ describe("matrix thread context", () => { ).toBe("Thread starter body"); }); + it("marks media-only thread starter events instead of returning bare filenames", () => { + expect( + summarizeMatrixThreadStarterEvent({ + event_id: "$root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: Date.now(), + content: { + msgtype: "m.image", + body: "photo.jpg", + }, + } as MatrixRawEvent), + ).toBe("[matrix image attachment]"); + }); + it("resolves and caches thread starter context", async () => { const getEvent = vi.fn(async () => ({ event_id: "$root", diff --git a/extensions/matrix/src/matrix/monitor/thread-context.ts b/extensions/matrix/src/matrix/monitor/thread-context.ts index 08a81a0d782..9a9fc3a29cc 100644 --- a/extensions/matrix/src/matrix/monitor/thread-context.ts +++ b/extensions/matrix/src/matrix/monitor/thread-context.ts @@ -1,3 +1,8 @@ +import { + formatMatrixMessageText, + resolveMatrixMessageAttachment, + resolveMatrixMessageBody, +} from "../media-text.js"; import type { MatrixClient } from "../sdk.js"; import type { MatrixRawEvent } from "./types.js"; @@ -24,8 +29,19 @@ function truncateThreadStarterBody(value: string): string { } export function summarizeMatrixThreadStarterEvent(event: MatrixRawEvent): string | undefined { - const content = event.content as { body?: unknown; msgtype?: unknown }; - const body = trimMaybeString(content.body); + const content = event.content as { body?: unknown; filename?: unknown; msgtype?: unknown }; + const body = formatMatrixMessageText({ + body: resolveMatrixMessageBody({ + body: trimMaybeString(content.body), + filename: trimMaybeString(content.filename), + msgtype: trimMaybeString(content.msgtype), + }), + attachment: resolveMatrixMessageAttachment({ + body: trimMaybeString(content.body), + filename: trimMaybeString(content.filename), + msgtype: trimMaybeString(content.msgtype), + }), + }); if (body) { return truncateThreadStarterBody(body); }