feat: add sendFile support to Signal channel adapter (#407)

This commit is contained in:
Cameron
2026-02-26 11:18:47 -08:00
committed by GitHub
parent a167679dec
commit 64a0e4b7d8
5 changed files with 193 additions and 6 deletions

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from 'vitest';
import { SignalAdapter } from './signal.js';
describe('SignalAdapter sendFile', () => {
function createAdapter(phone = '+15555555555') {
return new SignalAdapter({ phoneNumber: phone });
}
it('sends attachment to a direct message recipient', async () => {
const adapter = createAdapter();
const rpcSpy = vi.spyOn(adapter as any, 'rpcRequest').mockResolvedValue({ timestamp: 12345 });
const result = await adapter.sendFile({
chatId: '+12223334444',
filePath: '/tmp/voice.ogg',
});
expect(rpcSpy).toHaveBeenCalledWith('send', {
attachment: ['/tmp/voice.ogg'],
account: '+15555555555',
recipient: ['+12223334444'],
});
expect(result.messageId).toBe('12345');
});
it('sends attachment to a group', async () => {
const adapter = createAdapter();
const rpcSpy = vi.spyOn(adapter as any, 'rpcRequest').mockResolvedValue({ timestamp: 99 });
await adapter.sendFile({
chatId: 'group:abc123',
filePath: '/tmp/photo.png',
kind: 'image',
});
expect(rpcSpy).toHaveBeenCalledWith('send', {
attachment: ['/tmp/photo.png'],
account: '+15555555555',
groupId: 'abc123',
});
});
it('includes caption as message text', async () => {
const adapter = createAdapter();
const rpcSpy = vi.spyOn(adapter as any, 'rpcRequest').mockResolvedValue({ timestamp: 1 });
await adapter.sendFile({
chatId: '+12223334444',
filePath: '/tmp/report.pdf',
caption: 'Here is the report',
});
expect(rpcSpy).toHaveBeenCalledWith('send', {
attachment: ['/tmp/report.pdf'],
message: 'Here is the report',
account: '+15555555555',
recipient: ['+12223334444'],
});
});
it('sends to own number for note-to-self', async () => {
const adapter = createAdapter('+19998887777');
const rpcSpy = vi.spyOn(adapter as any, 'rpcRequest').mockResolvedValue({ timestamp: 42 });
await adapter.sendFile({
chatId: 'note-to-self',
filePath: '/tmp/memo.ogg',
kind: 'audio',
});
expect(rpcSpy).toHaveBeenCalledWith('send', {
attachment: ['/tmp/memo.ogg'],
account: '+19998887777',
recipient: ['+19998887777'],
});
});
it('returns unknown when no timestamp in response', async () => {
const adapter = createAdapter();
vi.spyOn(adapter as any, 'rpcRequest').mockResolvedValue({});
const result = await adapter.sendFile({
chatId: '+12223334444',
filePath: '/tmp/file.txt',
});
expect(result.messageId).toBe('unknown');
});
});

View File

@@ -6,7 +6,7 @@
*/ */
import type { ChannelAdapter } from './types.js'; import type { ChannelAdapter } from './types.js';
import type { InboundAttachment, InboundMessage, OutboundMessage } from '../core/types.js'; import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js';
import { applySignalGroupGating } from './signal/group-gating.js'; import { applySignalGroupGating } from './signal/group-gating.js';
import type { DmPolicy } from '../pairing/types.js'; import type { DmPolicy } from '../pairing/types.js';
import { import {
@@ -305,6 +305,36 @@ This code expires in 1 hour.`;
}; };
} }
async sendFile(file: OutboundFile): Promise<{ messageId: string }> {
const params: Record<string, unknown> = {
attachment: [file.filePath],
};
// Include caption as the message text
if (file.caption) {
params.message = file.caption;
}
if (this.config.phoneNumber) {
params.account = this.config.phoneNumber;
}
const target = file.chatId === 'note-to-self' ? this.config.phoneNumber : file.chatId;
if (target.startsWith('group:')) {
params.groupId = target.slice('group:'.length);
} else {
params.recipient = [target];
}
const result = await this.rpcRequest<{ timestamp?: number }>('send', params);
const timestamp = result?.timestamp;
return {
messageId: timestamp ? String(timestamp) : 'unknown',
};
}
getDmPolicy(): string { getDmPolicy(): string {
return this.config.dmPolicy || 'pairing'; return this.config.dmPolicy || 'pairing';
} }

View File

@@ -529,7 +529,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
]; ];
for (const [name, raw, included] of channelCredentials) { for (const [name, raw, included] of channelCredentials) {
if (raw && (raw as Record<string, unknown>).enabled !== false && !included) { if (raw && (raw as Record<string, unknown>).enabled !== false && !included) {
console.warn(`[Config] Channel '${name}' is in ${sourcePath} but missing required credentials -- skipping. Check your lettabot.yaml or environment variables.`); log.warn(`Channel '${name}' is in ${sourcePath} but missing required credentials -- skipping. Check your lettabot.yaml or environment variables.`);
} }
} }

View File

@@ -1065,7 +1065,11 @@ export class LettaBot implements AgentSession {
let oldestKey: string | null = null; let oldestKey: string | null = null;
let oldestTime = Infinity; let oldestTime = Infinity;
for (const [k, ts] of this.sessionLastUsed) { for (const [k, ts] of this.sessionLastUsed) {
if (k !== key && ts < oldestTime && this.sessions.has(k)) { if (k === key) continue;
if (!this.sessions.has(k)) continue;
// Never evict an active/in-flight key (can close a live stream).
if (this.processingKeys.has(k) || this.sessionCreationLocks.has(k)) continue;
if (ts < oldestTime) {
oldestKey = k; oldestKey = k;
oldestTime = ts; oldestTime = ts;
} }
@@ -1078,6 +1082,9 @@ export class LettaBot implements AgentSession {
this.sessionLastUsed.delete(oldestKey); this.sessionLastUsed.delete(oldestKey);
this.sessionGenerations.delete(oldestKey); this.sessionGenerations.delete(oldestKey);
this.sessionCreationLocks.delete(oldestKey); this.sessionCreationLocks.delete(oldestKey);
} else {
// All existing sessions are active; allow temporary overflow.
log.debug(`LRU session eviction skipped: all ${this.sessions.size} sessions are active/in-flight`);
} }
} }
@@ -1137,9 +1144,10 @@ export class LettaBot implements AgentSession {
this.store.refresh(); this.store.refresh();
if (!this.store.agentId && !this.store.conversationId) return; if (!this.store.agentId && !this.store.conversationId) return;
try { try {
// In shared mode, warm the single session. In per-channel mode, warm nothing const mode = this.config.conversationMode || 'shared';
// (sessions are created on first message per channel). // In shared mode, warm the single session. In per-channel/per-chat modes,
if (this.config.conversationMode !== 'per-channel') { // warm nothing (sessions are created on first message per key).
if (mode === 'shared') {
await this.ensureSessionForKey('shared'); await this.ensureSessionForKey('shared');
} }
} catch (err) { } catch (err) {

View File

@@ -262,6 +262,20 @@ describe('SDK session contract', () => {
expect(vi.mocked(createSession)).toHaveBeenCalledTimes(1); expect(vi.mocked(createSession)).toHaveBeenCalledTimes(1);
}); });
it('does not pre-warm a shared session in per-chat mode', async () => {
const bot = new LettaBot({
workingDir: join(dataDir, 'working'),
allowedTools: [],
conversationMode: 'per-chat',
});
bot.setAgentId('agent-contract-test');
await bot.warmSession();
expect(vi.mocked(createSession)).not.toHaveBeenCalled();
expect(vi.mocked(resumeSession)).not.toHaveBeenCalled();
});
it('passes memfs: true to createSession when config sets memfs true', async () => { it('passes memfs: true to createSession when config sets memfs true', async () => {
const mockSession = { const mockSession = {
initialize: vi.fn(async () => undefined), initialize: vi.fn(async () => undefined),
@@ -378,6 +392,52 @@ describe('SDK session contract', () => {
expect(processSpy).toHaveBeenCalledWith('slack'); expect(processSpy).toHaveBeenCalledWith('slack');
}); });
it('LRU eviction in per-chat mode does not close active keys', async () => {
const createdSession = {
initialize: vi.fn(async () => undefined),
send: vi.fn(async (_message: unknown) => undefined),
stream: vi.fn(() =>
(async function* () {
yield { type: 'result', success: true };
})()
),
close: vi.fn(() => undefined),
agentId: 'agent-contract-test',
conversationId: 'conv-new',
};
vi.mocked(createSession).mockReturnValue(createdSession as never);
const activeSession = {
close: vi.fn(() => undefined),
};
const idleSession = {
close: vi.fn(() => undefined),
};
const bot = new LettaBot({
workingDir: join(dataDir, 'working'),
allowedTools: [],
conversationMode: 'per-chat',
maxSessions: 2,
});
bot.setAgentId('agent-contract-test');
const botInternal = bot as any;
botInternal.sessions.set('telegram:active', activeSession);
botInternal.sessions.set('telegram:idle', idleSession);
botInternal.sessionLastUsed.set('telegram:active', 1);
botInternal.sessionLastUsed.set('telegram:idle', 2);
botInternal.processingKeys.add('telegram:active');
await botInternal._createSessionForKey('telegram:new', true, 0);
expect(activeSession.close).not.toHaveBeenCalled();
expect(idleSession.close).toHaveBeenCalledTimes(1);
expect(botInternal.sessions.has('telegram:active')).toBe(true);
expect(botInternal.sessions.has('telegram:idle')).toBe(false);
expect(botInternal.sessions.has('telegram:new')).toBe(true);
});
it('enriches opaque error via stream error event in sendToAgent', async () => { it('enriches opaque error via stream error event in sendToAgent', async () => {
const mockSession = { const mockSession = {
initialize: vi.fn(async () => undefined), initialize: vi.fn(async () => undefined),