From f7d8005be4e3d93e22e82ff70c693ae9a6ff4857 Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 9 Mar 2026 12:01:53 -0700 Subject: [PATCH] feat: add directive and cross-channel targeting for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `` directive that lets the agent proactively send text messages to any connected channel:chat, and extends `` with optional `channel`/`chat` attributes for targeted file delivery. Both work from any context including heartbeats and cron. Written by Cameron ◯ Letta Code "The question of whether a computer can think is no more interesting than the question of whether a submarine can swim." -- Edsger Dijkstra --- docs/directives.md | 24 ++++++++- src/core/bot.ts | 41 +++++++++++++--- src/core/directives.test.ts | 98 +++++++++++++++++++++++++++++++++++++ src/core/directives.ts | 37 ++++++++++++-- 4 files changed, 187 insertions(+), 13 deletions(-) diff --git a/docs/directives.md b/docs/directives.md index e489fd5..8eb6a7b 100644 --- a/docs/directives.md +++ b/docs/directives.md @@ -41,15 +41,33 @@ Adds an emoji reaction to a message. - Unicode emoji: direct characters like `👍` - `message` (optional) -- Target message ID. Defaults to the message that triggered the response. +### `` + +Sends a text message to a specific channel and chat. Unlike normal response text (which goes to the triggering chat), this directive lets the agent proactively send messages to any connected chat -- useful for async job notifications, multi-tenant workflows, or cross-channel delivery. + +```xml +Your transcription is ready! +Job #42 completed successfully. +``` + +**Attributes:** +- `channel` (required) -- Target channel ID (`telegram`, `slack`, `discord`, `whatsapp`, `signal`) +- `chat` (required) -- Target chat/conversation ID on that channel + +**Text content** between the opening and closing tags is the message body. Empty messages are ignored. + +Works from any context including heartbeats and cron jobs (silent mode). The agent must know the target channel and chat ID -- these are visible in the formatter envelope of inbound messages (e.g. `[WhatsApp:5511999999999 ...]`). + ### `` -Sends a file or image to the same channel/chat as the triggering message. +Sends a file or image. By default, sends to the same channel/chat as the triggering message. With optional `channel` and `chat` attributes, can target a different chat (cross-channel file delivery). ```xml + ``` **Attributes:** @@ -57,6 +75,8 @@ Sends a file or image to the same channel/chat as the triggering message. - `caption` / `text` (optional) -- Caption text for the file - `kind` (optional) -- `image`, `file`, or `audio` (defaults to auto-detect based on extension). Audio files (.ogg, .opus, .mp3, .m4a, .wav, .aac, .flac) are auto-detected as `audio`. - `cleanup` (optional) -- `true` to delete the file after sending (default: false) +- `channel` (optional) -- Target channel ID for cross-channel delivery +- `chat` (optional) -- Target chat ID for cross-channel delivery (both `channel` and `chat` must be set) **Security:** - File paths are restricted to the configured `sendFileDir` directory (defaults to `data/outbound/` under the agent's working directory). Paths outside this directory are blocked and logged. @@ -146,7 +166,7 @@ During streaming, the bot holds back display while the response could still be a The parser (`src/core/directives.ts`) is designed to be extensible. Adding a new directive type involves: -1. Add the tag name to `CHILD_DIRECTIVE_REGEX` (e.g. `<(react|send-file)`) +1. Add the tag name to `DIRECTIVE_TOKEN_REGEX` (self-closing) or its content-bearing alternation 2. Add a new interface to the `Directive` union type 3. Add a parsing case in `parseChildDirectives()` 4. Add an execution case in `executeDirectives()` in `bot.ts` diff --git a/src/core/bot.ts b/src/core/bot.ts index 644a0aa..cf84bee 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -345,9 +345,37 @@ export class LettaBot implements AgentSession { continue; } + if (directive.type === 'send-message') { + // Targeted message delivery to a specific channel:chat. + try { + const targetAdapter = this.channels.get(directive.channel); + if (!targetAdapter) { + log.warn(`Directive send-message skipped: channel "${directive.channel}" not registered`); + continue; + } + await targetAdapter.sendMessage({ chatId: directive.chat, text: this.prefixResponse(directive.text) }); + acted = true; + log.info(`Directive: sent message to ${directive.channel}:${directive.chat} (${directive.text.length} chars)`); + } catch (err) { + log.warn('Directive send-message failed:', err instanceof Error ? err.message : err); + } + continue; + } + if (directive.type === 'send-file') { - if (typeof adapter.sendFile !== 'function') { - log.warn(`Directive send-file skipped: ${adapter.name} does not support sendFile`); + // Resolve target adapter: use cross-channel targeting if both channel and chat are set, + // otherwise fall back to the adapter/chatId that triggered this response. + const targetAdapter = (directive.channel && directive.chat) + ? this.channels.get(directive.channel) + : adapter; + const targetChatId = (directive.channel && directive.chat) ? directive.chat : chatId; + + if (!targetAdapter) { + log.warn(`Directive send-file skipped: channel "${directive.channel}" not registered`); + continue; + } + if (typeof targetAdapter.sendFile !== 'function') { + log.warn(`Directive send-file skipped: ${targetAdapter.name} does not support sendFile`); continue; } @@ -383,15 +411,16 @@ export class LettaBot implements AgentSession { } try { - await adapter.sendFile({ - chatId, + await targetAdapter.sendFile({ + chatId: targetChatId, filePath: resolvedPath, caption: directive.caption, kind: directive.kind ?? inferFileKind(resolvedPath), - threadId, + threadId: (directive.channel && directive.chat) ? undefined : threadId, }); acted = true; - log.info(`Directive: sent file ${resolvedPath}`); + const target = (directive.channel && directive.chat) ? ` to ${directive.channel}:${directive.chat}` : ''; + log.info(`Directive: sent file ${resolvedPath}${target}`); // Optional cleanup: delete file after successful send. // Only honored when sendFileCleanup is enabled in config (defense-in-depth). diff --git a/src/core/directives.test.ts b/src/core/directives.test.ts index 58d2503..00e29d3 100644 --- a/src/core/directives.test.ts +++ b/src/core/directives.test.ts @@ -208,6 +208,104 @@ describe('parseDirectives', () => { { type: 'voice', text: 'Two' }, ]); }); + + // --- send-message directive --- + + it('parses send-message directive with channel and chat', () => { + const result = parseDirectives( + 'Your transcription is ready', + ); + expect(result.cleanText).toBe(''); + expect(result.directives).toEqual([ + { type: 'send-message', text: 'Your transcription is ready', channel: 'whatsapp', chat: '5511999999999' }, + ]); + }); + + it('parses send-message with text after actions block', () => { + const result = parseDirectives( + 'Done!\nHere is the summary.', + ); + expect(result.cleanText).toBe('Here is the summary.'); + expect(result.directives).toEqual([ + { type: 'send-message', text: 'Done!', channel: 'telegram', chat: '123' }, + ]); + }); + + it('parses send-message with multiline text', () => { + const result = parseDirectives( + 'Line one.\nLine two.', + ); + expect(result.directives).toEqual([ + { type: 'send-message', text: 'Line one.\nLine two.', channel: 'slack', chat: 'C123' }, + ]); + }); + + it('ignores send-message without channel attribute', () => { + const result = parseDirectives( + 'Hello', + ); + expect(result.directives).toEqual([]); + }); + + it('ignores send-message without chat attribute', () => { + const result = parseDirectives( + 'Hello', + ); + expect(result.directives).toEqual([]); + }); + + it('ignores send-message with empty text', () => { + const result = parseDirectives( + ' ', + ); + expect(result.directives).toEqual([]); + }); + + it('parses multiple send-message directives', () => { + const result = parseDirectives( + '' + + 'Hello user 1' + + 'Hello user 2' + + '', + ); + expect(result.directives).toHaveLength(2); + expect(result.directives[0]).toEqual({ type: 'send-message', text: 'Hello user 1', channel: 'whatsapp', chat: '111' }); + expect(result.directives[1]).toEqual({ type: 'send-message', text: 'Hello user 2', channel: 'telegram', chat: '222' }); + }); + + it('parses send-message mixed with other directives', () => { + const result = parseDirectives( + '' + + '' + + 'Result ready' + + '' + + '', + ); + expect(result.directives).toHaveLength(3); + expect(result.directives[0]).toEqual({ type: 'react', emoji: 'thumbsup' }); + expect(result.directives[1]).toEqual({ type: 'send-message', text: 'Result ready', channel: 'whatsapp', chat: '555' }); + expect(result.directives[2]).toEqual({ type: 'send-file', path: 'report.pdf' }); + }); + + // --- send-file with channel/chat targeting --- + + it('parses send-file with channel and chat targeting', () => { + const result = parseDirectives( + '', + ); + expect(result.directives).toEqual([ + { type: 'send-file', path: 'result.txt', channel: 'whatsapp', chat: '5511999999999', caption: 'Here you go' }, + ]); + }); + + it('parses send-file without channel/chat (default behavior unchanged)', () => { + const result = parseDirectives( + '', + ); + expect(result.directives).toEqual([ + { type: 'send-file', path: 'report.pdf', caption: 'Report' }, + ]); + }); }); describe('stripActionsBlock', () => { diff --git a/src/core/directives.ts b/src/core/directives.ts index e26b284..8e2197f 100644 --- a/src/core/directives.ts +++ b/src/core/directives.ts @@ -28,6 +28,15 @@ export interface SendFileDirective { caption?: string; kind?: 'image' | 'file' | 'audio'; cleanup?: boolean; + channel?: string; + chat?: string; +} + +export interface SendMessageDirective { + type: 'send-message'; + text: string; + channel: string; + chat: string; } export interface VoiceDirective { @@ -36,7 +45,7 @@ export interface VoiceDirective { } // Union type — extend with more directive types later -export type Directive = ReactDirective | SendFileDirective | VoiceDirective; +export type Directive = ReactDirective | SendFileDirective | SendMessageDirective | VoiceDirective; export interface ParseResult { cleanText: string; @@ -52,9 +61,16 @@ const ACTIONS_BLOCK_REGEX = /^\s*([\s\S]*?)<\/actions>/; /** * Match supported directive tags inside the actions block in source order. * - Self-closing: , - * - Content-bearing: ... + * - Content-bearing: ..., ... + * + * Groups: + * 1: self-closing tag name (react|send-file) + * 2: self-closing attribute string + * 3: text content + * 4: attribute string + * 5: text content */ -const DIRECTIVE_TOKEN_REGEX = /<(react|send-file)\b([^>]*)\/>|([\s\S]*?)<\/voice>/g; +const DIRECTIVE_TOKEN_REGEX = /<(react|send-file)\b([^>]*)\/>|([\s\S]*?)<\/voice>|]*)>([\s\S]*?)<\/send-message>/g; /** * Parse a single attribute string like: emoji="eyes" message="123" @@ -76,13 +92,13 @@ function parseAttributes(attrString: string): Record { function parseChildDirectives(block: string): Directive[] { const directives: Directive[] = []; let match; - const normalizedBlock = block.replace(/\\(['"])/g, '$1'); + const normalizedBlock = block.replace(/\\(['""])/g, '$1'); // Reset regex state (global flag) DIRECTIVE_TOKEN_REGEX.lastIndex = 0; while ((match = DIRECTIVE_TOKEN_REGEX.exec(normalizedBlock)) !== null) { - const [, tagName, attrString, voiceText] = match; + const [, tagName, attrString, voiceText, sendMsgAttrs, sendMsgText] = match; if (voiceText !== undefined) { const text = voiceText.trim(); @@ -92,6 +108,15 @@ function parseChildDirectives(block: string): Directive[] { continue; } + if (sendMsgText !== undefined) { + const text = sendMsgText.trim(); + const attrs = parseAttributes(sendMsgAttrs || ''); + if (text && attrs.channel && attrs.chat) { + directives.push({ type: 'send-message', text, channel: attrs.channel, chat: attrs.chat }); + } + continue; + } + if (tagName === 'react') { const attrs = parseAttributes(attrString || ''); if (attrs.emoji) { @@ -119,6 +144,8 @@ function parseChildDirectives(block: string): Directive[] { ...(caption ? { caption } : {}), ...(kind ? { kind } : {}), ...(cleanup ? { cleanup } : {}), + ...(attrs.channel ? { channel: attrs.channel } : {}), + ...(attrs.chat ? { chat: attrs.chat } : {}), }); } }