From 80a160a4ebda8cc4bbd9440247e3f8c97fca5a96 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 17 Feb 2026 10:32:01 -0800 Subject: [PATCH] feat(channels): implement binary uploads for matrix signal mattermost --- docs/plans/state.json | 16 +++++ src/channels/matrix/adapter.test.ts | 33 +++++++++ src/channels/matrix/adapter.ts | 92 ++++++++++++++++++++----- src/channels/mattermost/adapter.test.ts | 35 ++++++++++ src/channels/mattermost/adapter.ts | 47 ++++++++++++- src/channels/signal/adapter.test.ts | 27 ++++++++ src/channels/signal/adapter.ts | 62 ++++++++++++++++- 7 files changed, 290 insertions(+), 22 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 056e103..0d7f868 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3606,6 +3606,22 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/skills/planner.test.ts + pnpm typecheck passing" + }, + "matrix-signal-mattermost-binary-attachments": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Implemented binary outbound attachment handling for Matrix (media upload + m.image/m.file send), Signal (temp-file `signal-cli --attachment` flow), and Mattermost (`/api/v4/files` upload + post `file_ids`) with adapter regression tests.", + "files_modified": [ + "src/channels/matrix/adapter.ts", + "src/channels/matrix/adapter.test.ts", + "src/channels/signal/adapter.ts", + "src/channels/signal/adapter.test.ts", + "src/channels/mattermost/adapter.ts", + "src/channels/mattermost/adapter.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/channels/matrix/adapter.test.ts src/channels/signal/adapter.test.ts src/channels/mattermost/adapter.test.ts + pnpm typecheck passing" } }, "overall_progress": { diff --git a/src/channels/matrix/adapter.test.ts b/src/channels/matrix/adapter.test.ts index c57f941..c08393e 100644 --- a/src/channels/matrix/adapter.test.ts +++ b/src/channels/matrix/adapter.test.ts @@ -113,6 +113,39 @@ describe('MatrixAdapter', () => { await adapter.send('!room1:example.org', { text: 'Hello there' }); }); + it('uploads binary attachments and sends Matrix media events', async () => { + mockFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.endsWith('/_matrix/client/v3/account/whoami')) { + return jsonResponse({ user_id: '@flynn:example.org' }); + } + if (url.includes('/account_data/m.direct')) { + return jsonResponse({}); + } + if (url.includes('/_matrix/client/v3/sync')) { + return new Promise(() => {}); + } + if (init?.method === 'POST' && url.includes('/_matrix/client/v3/upload')) { + return jsonResponse({ content_uri: 'mxc://example.org/media123' }); + } + if (init?.method === 'PUT' && url.includes('/send/m.room.message/')) { + const body = JSON.parse(String(init?.body ?? '{}')); + if (body.msgtype === 'm.image') { + expect(body.url).toBe('mxc://example.org/media123'); + expect(body.info?.mimetype).toBe('image/png'); + return jsonResponse({ event_id: '$media1' }); + } + return jsonResponse({ event_id: '$text1' }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + await adapter.connect(); + await adapter.send('!room1:example.org', { + text: 'Attachment incoming', + attachments: [{ mimeType: 'image/png', data: 'aGVsbG8=', filename: 'image.png' }], + }); + }); + it('inbound message requires mention in non-DM rooms', async () => { const handler = vi.fn(); adapter.onMessage(handler); diff --git a/src/channels/matrix/adapter.ts b/src/channels/matrix/adapter.ts index c3611cf..cf899e5 100644 --- a/src/channels/matrix/adapter.ts +++ b/src/channels/matrix/adapter.ts @@ -63,6 +63,10 @@ interface MatrixEvent { }; } +interface MatrixUploadResponse { + content_uri?: string; +} + const MAX_MESSAGE_LENGTH = 65536; const DEFAULT_SYNC_TIMEOUT_MS = 30_000; const SYNC_ERROR_BACKOFF_MS = 5_000; @@ -142,30 +146,82 @@ export class MatrixAdapter implements ChannelAdapter { } const text = (message.text ?? '').trim(); - if (!text) { + const attachments = message.attachments ?? []; + if (!text && attachments.length === 0) { return; } - const chunks = text.length > MAX_MESSAGE_LENGTH - ? splitMessage(text, MAX_MESSAGE_LENGTH) - : [text]; + if (text) { + const chunks = text.length > MAX_MESSAGE_LENGTH + ? splitMessage(text, MAX_MESSAGE_LENGTH) + : [text]; - for (const chunk of chunks) { - if (!chunk) {continue;} - await this.sendRoomMessage(peerId, chunk, message.replyTo); - } - - if (message.attachments && message.attachments.length > 0) { - for (const a of message.attachments) { - if (a.url) { - const line = a.filename ? `${a.filename}: ${a.url}` : a.url; - await this.sendRoomMessage(peerId, line); - } else if (a.data) { - // MVP: don't attempt media upload yet. - console.warn(`Matrix: skipping attachment data (${a.mimeType}) — upload not implemented`); - } + for (const chunk of chunks) { + if (!chunk) {continue;} + await this.sendRoomMessage(peerId, chunk, message.replyTo); } } + + for (const a of attachments) { + if (a.url) { + const line = a.filename ? `${a.filename}: ${a.url}` : a.url; + await this.sendRoomMessage(peerId, line); + } else if (a.data) { + const mxcUrl = await this.uploadAttachment(a.data, a.mimeType, a.filename); + await this.sendRoomAttachment(peerId, { + mxcUrl, + mimeType: a.mimeType, + filename: a.filename, + }); + } + } + } + + private async uploadAttachment(base64Data: string, mimeType: string, filename?: string): Promise { + const payload = Buffer.from(base64Data, 'base64'); + const url = new URL('/_matrix/client/v3/upload', this.config.homeserverUrl); + if (filename) { + url.searchParams.set('filename', filename); + } + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.accessToken}`, + 'Content-Type': mimeType || 'application/octet-stream', + }, + body: payload, + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`Matrix upload failed (${response.status}): ${text}`); + } + const json = await response.json() as MatrixUploadResponse; + if (!json.content_uri || typeof json.content_uri !== 'string') { + throw new Error('Matrix upload response missing content_uri'); + } + return json.content_uri; + } + + private async sendRoomAttachment( + roomId: string, + attachment: { mxcUrl: string; mimeType: string; filename?: string }, + ): Promise { + const txnId = `m${Date.now()}_${this.txnCounter++}`; + const filename = attachment.filename ?? 'attachment'; + const msgtype = attachment.mimeType.startsWith('image/') ? 'm.image' : 'm.file'; + const payload = { + msgtype, + body: filename, + filename, + url: attachment.mxcUrl, + info: { + mimetype: attachment.mimeType, + }, + }; + await this.matrixPut( + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, + payload, + ); } private async runSyncLoop(signal: AbortSignal): Promise { diff --git a/src/channels/mattermost/adapter.test.ts b/src/channels/mattermost/adapter.test.ts index c16a604..3f53f8a 100644 --- a/src/channels/mattermost/adapter.test.ts +++ b/src/channels/mattermost/adapter.test.ts @@ -85,6 +85,41 @@ describe('MattermostAdapter', () => { }); }); + it('uploads binary attachments and posts with file_ids', async () => { + mockFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.endsWith('/api/v4/users/me')) { + return jsonResponse({ id: 'bot-user', username: 'flynnbot' }); + } + if (url.endsWith('/api/v4/channels/chan-1')) { + return jsonResponse({ id: 'chan-1', type: 'O' }); + } + if (url.includes('/api/v4/channels/chan-1/posts?since=')) { + return jsonResponse({ order: [], posts: {} }); + } + if (url.endsWith('/api/v4/files') && init?.method === 'POST') { + return jsonResponse({ file_infos: [{ id: 'file-1' }] }); + } + if (url.endsWith('/api/v4/posts') && init?.method === 'POST') { + const body = JSON.parse(String(init.body)); + if (Array.isArray(body.file_ids)) { + expect(body.channel_id).toBe('chan-1'); + expect(body.file_ids).toEqual(['file-1']); + return jsonResponse({ id: 'p2' }); + } + return jsonResponse({ id: 'p1' }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const adapter = new MattermostAdapter(baseConfig); + await adapter.connect(); + await adapter.send('chan-1', { + text: '', + attachments: [{ mimeType: 'image/png', data: 'aGVsbG8=', filename: 'image.png' }], + }); + await adapter.disconnect(); + }); + it('enforces channel allowlist and mention gating', async () => { const adapter = new MattermostAdapter(baseConfig); const messages: InboundMessage[] = []; diff --git a/src/channels/mattermost/adapter.ts b/src/channels/mattermost/adapter.ts index 7ae87b9..16d4517 100644 --- a/src/channels/mattermost/adapter.ts +++ b/src/channels/mattermost/adapter.ts @@ -53,6 +53,10 @@ interface MattermostPostsResponse { posts?: Record; } +interface MattermostUploadResponse { + file_infos?: Array<{ id?: string }>; +} + const DEFAULT_POLL_INTERVAL_MS = 3000; const MAX_MESSAGE_LENGTH = 3500; @@ -147,8 +151,8 @@ export class MattermostAdapter implements ChannelAdapter { const line = a.filename ? `${a.filename}: ${a.url}` : a.url; await this.postMessage(peerId, line); } else if (a.data) { - // Keep initial adapter implementation stable: only URL attachment echoes. - console.warn(`Mattermost: skipping attachment data (${a.mimeType}) — upload not implemented`); + const fileId = await this.uploadAttachment(peerId, a.data, a.filename, a.mimeType); + await this.postMessageWithFiles(peerId, a.filename ?? 'Attachment uploaded', [fileId]); } } } @@ -165,6 +169,45 @@ export class MattermostAdapter implements ChannelAdapter { }); } + private async postMessageWithFiles(channelId: string, text: string, fileIds: string[]): Promise { + await this.apiPost('/api/v4/posts', { + channel_id: channelId, + message: text, + file_ids: fileIds, + }); + } + + private async uploadAttachment( + channelId: string, + base64Data: string, + filename?: string, + mimeType?: string, + ): Promise { + const form = new FormData(); + const blob = new Blob([Buffer.from(base64Data, 'base64')], { + type: mimeType || 'application/octet-stream', + }); + form.append('channel_id', channelId); + form.append('files', blob, filename ?? 'attachment.bin'); + + const res = await fetch(this.makeUrl('/api/v4/files'), { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.botToken}`, + }, + body: form, + }); + if (!res.ok) { + throw new Error(`Mattermost POST /api/v4/files failed (${res.status}): ${await res.text()}`); + } + const json = await res.json() as MattermostUploadResponse; + const fileId = json.file_infos?.[0]?.id; + if (!fileId) { + throw new Error('Mattermost upload response missing file id'); + } + return fileId; + } + private async pollOnce(): Promise { if (this.polling || !this.messageHandler || this._status !== 'connected') { return; diff --git a/src/channels/signal/adapter.test.ts b/src/channels/signal/adapter.test.ts index a3d968f..bb020f8 100644 --- a/src/channels/signal/adapter.test.ts +++ b/src/channels/signal/adapter.test.ts @@ -68,6 +68,33 @@ describe('SignalAdapter', () => { expect(sendCall?.[1]).toEqual(['-u', '+15551234567', 'send', '-m', 'Hello group', '-g', 'abcd1234']); }); + it('send uploads binary attachments via --attachment', async () => { + const adapter = new SignalAdapter({ account: '+15551234567' }); + mockExecFileOnce((callback) => callback(null, 'signal-cli 0.13.2', '')); + mockExecFileOnce((callback) => callback(null, '', '')); + mockExecFileOnce((callback) => callback(null, '', '')); + + await adapter.connect(); + await adapter.send('+15550001111', { + text: '', + attachments: [{ mimeType: 'image/png', data: 'aGVsbG8=', filename: 'image.png' }], + }); + await adapter.disconnect(); + + const attachmentCall = mockExecFile.mock.calls.find((call) => { + const args = call[1]; + return Array.isArray(args) && args.includes('--attachment'); + }); + expect(attachmentCall).toBeDefined(); + const args = attachmentCall?.[1] as string[]; + expect(args[0]).toBe('-u'); + expect(args[1]).toBe('+15551234567'); + expect(args[2]).toBe('send'); + expect(args[3]).toBe('--attachment'); + expect(typeof args[4]).toBe('string'); + expect(args[5]).toBe('+15550001111'); + }); + it('parses DM receive payload and forwards inbound message', async () => { const adapter = new SignalAdapter({ account: '+15551234567', diff --git a/src/channels/signal/adapter.ts b/src/channels/signal/adapter.ts index de1b808..fac44c5 100644 --- a/src/channels/signal/adapter.ts +++ b/src/channels/signal/adapter.ts @@ -1,4 +1,7 @@ import { execFile } from 'child_process'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; import type { InboundMessage, @@ -63,6 +66,7 @@ export class SignalAdapter implements ChannelAdapter { private readonly config: SignalAdapterConfig; private pollTimer: NodeJS.Timeout | null = null; private polling = false; + private attachmentTempCounter = 0; get status(): ChannelStatus { return this._status; @@ -120,8 +124,7 @@ export class SignalAdapter implements ChannelAdapter { const line = a.filename ? `${a.filename}: ${a.url}` : a.url; await this.sendText(peerId, line); } else if (a.data) { - // Keep adapter minimal and robust: no temp-file attachment upload in this pass. - console.warn(`Signal: skipping attachment data (${a.mimeType}) — upload not implemented`); + await this.sendBinaryAttachment(peerId, a.data, a.filename, a.mimeType); } } } @@ -141,6 +144,31 @@ export class SignalAdapter implements ChannelAdapter { await this.execSignal(args); } + private async sendBinaryAttachment( + peerId: string, + base64Data: string, + filename?: string, + mimeType?: string, + ): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'flynn-signal-')); + const ext = extensionFromMimeType(mimeType); + const safeName = sanitizeFilename(filename) || `attachment${ext}`; + const tempPath = join(tempDir, `${this.attachmentTempCounter++}-${safeName}`); + try { + await writeFile(tempPath, Buffer.from(base64Data, 'base64')); + const args = ['-u', this.config.account, 'send', '--attachment', tempPath]; + const groupId = this.extractGroupId(peerId); + if (groupId) { + args.push('-g', groupId); + } else { + args.push(peerId); + } + await this.execSignal(args); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + private async pollOnce(): Promise { if (this.polling || !this.messageHandler || this._status !== 'connected') { return; @@ -327,6 +355,36 @@ export class SignalAdapter implements ChannelAdapter { } } +function extensionFromMimeType(mimeType?: string): string { + if (!mimeType) { + return '.bin'; + } + if (mimeType.startsWith('image/jpeg')) { + return '.jpg'; + } + if (mimeType.startsWith('image/png')) { + return '.png'; + } + if (mimeType.startsWith('image/gif')) { + return '.gif'; + } + if (mimeType.startsWith('application/pdf')) { + return '.pdf'; + } + const [, subtype] = mimeType.split('/'); + if (!subtype) { + return '.bin'; + } + return `.${subtype.split('+')[0]}`; +} + +function sanitizeFilename(filename?: string): string { + if (!filename) { + return ''; + } + return filename.replace(/[^a-zA-Z0-9._-]/g, '_'); +} + function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }