feat(channels): implement binary uploads for matrix signal mattermost
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, '\\$&');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user