feat(channels): implement binary uploads for matrix signal mattermost

This commit is contained in:
William Valentin
2026-02-17 10:32:01 -08:00
parent e158968e03
commit 80a160a4eb
7 changed files with 290 additions and 22 deletions
+16
View File
@@ -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": {
+33
View File
@@ -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<Response>(() => {});
}
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);
+74 -18
View File
@@ -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<string> {
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<void> {
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<void> {
+35
View File
@@ -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[] = [];
+45 -2
View File
@@ -53,6 +53,10 @@ interface MattermostPostsResponse {
posts?: Record<string, MattermostPost>;
}
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<void> {
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<string> {
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<void> {
if (this.polling || !this.messageHandler || this._status !== 'connected') {
return;
+27
View File
@@ -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',
+60 -2
View File
@@ -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<void> {
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<void> {
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, '\\$&');
}