diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts index 5d8d36948d9..c21db82b92c 100644 --- a/src/channels/signal.test.ts +++ b/src/channels/signal.test.ts @@ -337,31 +337,113 @@ describe('SignalAdapter', () => { await adapter.teardown(); }); - it('forwards image attachments as [Image: ] plus structured attachments array', async () => { + it('forwards image attachments as base64 in the structured attachments array', async () => { const adapter = createAdapter(); const cfg = createMockSetup(); await adapter.setup(cfg); - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }], - }, - }); + // signal.ts reads the attachment off disk to inline its bytes; the + // host's session-manager.extractAttachmentFiles then saves the base64 + // to the per-session inbox and rewrites the entry as `localPath`. + // Stage a real file at the path signal.ts will look at. + const fs = await import('node:fs'); + const path = await import('node:path'); + const attachDir = path.join('/tmp/signal-cli-test-data', 'attachments'); + fs.mkdirSync(attachDir, { recursive: true }); + const fakeBytes = Buffer.from('fake-image-bytes'); + const attachFile = path.join(attachDir, 'att123abc'); + fs.writeFileSync(attachFile, fakeBytes); - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550123', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: expect.stringMatching(/^\[Image: .+att123abc\]$/), - attachments: [expect.objectContaining({ contentType: 'image/jpeg' })], + try { + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }], + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + // No more `[Image: ]` line in the text — the host + // will surface the saved file via the attachments array. + text: '', + attachments: [ + expect.objectContaining({ + data: fakeBytes.toString('base64'), + name: 'att123abc.jpeg', + type: 'image', + contentType: 'image/jpeg', + size: fakeBytes.length, + }), + ], + }), }), - }), - ); + ); + } finally { + fs.unlinkSync(attachFile); + } + + await adapter.teardown(); + }); + + it('forwards PDF attachments as base64 with original filename and type=document', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + const fs = await import('node:fs'); + const path = await import('node:path'); + const attachDir = path.join('/tmp/signal-cli-test-data', 'attachments'); + fs.mkdirSync(attachDir, { recursive: true }); + const fakeBytes = Buffer.from('%PDF-1.4 fake'); + const attachFile = path.join(attachDir, 'pdfid123'); + fs.writeFileSync(attachFile, fakeBytes); + + try { + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + attachments: [ + { + id: 'pdfid123', + contentType: 'application/pdf', + filename: 'report.pdf', + size: fakeBytes.length, + }, + ], + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: '', + attachments: [ + expect.objectContaining({ + data: fakeBytes.toString('base64'), + name: 'report.pdf', + type: 'document', + contentType: 'application/pdf', + size: fakeBytes.length, + }), + ], + }), + }), + ); + } finally { + fs.unlinkSync(attachFile); + } await adapter.teardown(); }); @@ -599,9 +681,7 @@ describe('SignalAdapter', () => { ); expect((sendCalls[0].params as Record).attachments).toBeUndefined(); // Second call: attachment, no message - expect(sendCalls[1].params).toEqual( - expect.objectContaining({ recipient: ['+15555550123'] }), - ); + expect(sendCalls[1].params).toEqual(expect.objectContaining({ recipient: ['+15555550123'] })); const attachments = (sendCalls[1].params as Record).attachments as string[]; expect(attachments).toHaveLength(1); diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 1f490c18b64..dad4faa46bc 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -590,9 +590,10 @@ export function createSignalAdapter(config: { const audioAttachment = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/') && a.id); const imageAttachments = dataMessage.attachments?.filter((a) => a.contentType?.startsWith('image/') && a.id) ?? []; + const pdfAttachments = dataMessage.attachments?.filter((a) => a.contentType === 'application/pdf' && a.id) ?? []; const hasVoice = !text && !!audioAttachment; - if (!text && !hasVoice && imageAttachments.length === 0) return; + if (!text && !hasVoice && imageAttachments.length === 0 && pdfAttachments.length === 0) return; const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); if (!sender) return; @@ -647,17 +648,54 @@ export function createSignalAdapter(config: { } } - // Image attachments — emit `[Image: ]` lines so the agent's Read - // tool can pick them up, and surface the structured `attachments` array - // for consumers that prefer that shape. Without this, vision-capable - // models never see images sent over Signal. - const attachmentRefs: Array<{ path: string; contentType: string }> = []; - for (const img of imageAttachments) { - const imagePath = join(config.signalDataDir, 'attachments', img.id!); - const imageLine = `[Image: ${imagePath}]`; - content = content ? `${content}\n${imageLine}` : imageLine; - attachmentRefs.push({ path: imagePath, contentType: img.contentType || 'image/jpeg' }); - } + // Image and PDF attachments — read each file and inline as base64 in + // the structured `attachments` array. The host's + // session-manager.extractAttachmentFiles writes the base64 to + // /inbox// (which is /workspace/inbox/... + // inside the container) and rewrites the entry as `localPath`. The + // container formatter then renders `[image|document: — saved + // to /workspace/inbox/.../...]` so the agent can Read it. + // + // Earlier versions pushed `{ path: , ... }` instead, but the + // signal-cli attachments directory is not mounted into the container, + // so the agent received a path that didn't resolve. + const attachmentRefs: Array<{ + data: string; + name: string; + type: string; + contentType: string; + size: number; + }> = []; + const inlineAttachment = ( + att: { id?: string; contentType?: string; filename?: string }, + kind: 'image' | 'document', + defaultContentType: string, + ) => { + const attachPath = join(config.signalDataDir, 'attachments', att.id!); + if (!existsSync(attachPath)) { + log.warn(`Signal: ${kind} attachment file not found`, { id: att.id, path: attachPath }); + return; + } + try { + const buf = readFileSync(attachPath); + const ct = att.contentType || defaultContentType; + // Prefer the original filename when signal-cli supplies one (common + // for documents); otherwise synthesize .. + const ext = (ct.split('/')[1] ?? 'bin').replace(/[^a-zA-Z0-9]/g, '') || 'bin'; + const name = att.filename || `${att.id}.${ext}`; + attachmentRefs.push({ + data: buf.toString('base64'), + name, + type: kind, + contentType: ct, + size: buf.length, + }); + } catch (err) { + log.warn(`Signal: failed to read ${kind} attachment`, { id: att.id, err }); + } + }; + for (const img of imageAttachments) inlineAttachment(img, 'image', 'image/jpeg'); + for (const pdf of pdfAttachments) inlineAttachment(pdf, 'document', 'application/pdf'); const msg: InboundMessage = { id: String(dataMessage.timestamp ?? Date.now()),