feat: add sendFile support to Signal channel adapter (#407)
This commit is contained in:
89
src/channels/signal.test.ts
Normal file
89
src/channels/signal.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
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 type { DmPolicy } from '../pairing/types.js';
|
||||
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 {
|
||||
return this.config.dmPolicy || 'pairing';
|
||||
}
|
||||
|
||||
@@ -529,7 +529,7 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
|
||||
];
|
||||
for (const [name, raw, included] of channelCredentials) {
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1065,7 +1065,11 @@ export class LettaBot implements AgentSession {
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
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;
|
||||
oldestTime = ts;
|
||||
}
|
||||
@@ -1078,6 +1082,9 @@ export class LettaBot implements AgentSession {
|
||||
this.sessionLastUsed.delete(oldestKey);
|
||||
this.sessionGenerations.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();
|
||||
if (!this.store.agentId && !this.store.conversationId) return;
|
||||
try {
|
||||
// In shared mode, warm the single session. In per-channel mode, warm nothing
|
||||
// (sessions are created on first message per channel).
|
||||
if (this.config.conversationMode !== 'per-channel') {
|
||||
const mode = this.config.conversationMode || 'shared';
|
||||
// In shared mode, warm the single session. In per-channel/per-chat modes,
|
||||
// warm nothing (sessions are created on first message per key).
|
||||
if (mode === 'shared') {
|
||||
await this.ensureSessionForKey('shared');
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -262,6 +262,20 @@ describe('SDK session contract', () => {
|
||||
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 () => {
|
||||
const mockSession = {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
@@ -378,6 +392,52 @@ describe('SDK session contract', () => {
|
||||
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 () => {
|
||||
const mockSession = {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
|
||||
Reference in New Issue
Block a user