Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 102 additions & 22 deletions src/channels/signal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,31 +337,113 @@ describe('SignalAdapter', () => {
await adapter.teardown();
});

it('forwards image attachments as [Image: <path>] 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: <hostpath>]` 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();
});
Expand Down Expand Up @@ -599,9 +681,7 @@ describe('SignalAdapter', () => {
);
expect((sendCalls[0].params as Record<string, unknown>).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<string, unknown>).attachments as string[];
expect(attachments).toHaveLength(1);

Expand Down
62 changes: 50 additions & 12 deletions src/channels/signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -647,17 +648,54 @@ export function createSignalAdapter(config: {
}
}

// Image attachments — emit `[Image: <path>]` 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
// <sessionDir>/inbox/<msgId>/<filename> (which is /workspace/inbox/...
// inside the container) and rewrites the entry as `localPath`. The
// container formatter then renders `[image|document: <name> — saved
// to /workspace/inbox/.../...]` so the agent can Read it.
//
// Earlier versions pushed `{ path: <host-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 <id>.<ext-from-contentType>.
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()),
Expand Down
Loading