feat: add <send-message> directive and cross-channel targeting for <send-file>

Adds a new `<send-message>` directive that lets the agent proactively
send text messages to any connected channel:chat, and extends
`<send-file>` 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
This commit is contained in:
Cameron
2026-03-09 12:01:53 -07:00
parent d7ab7f1c4b
commit f7d8005be4
4 changed files with 187 additions and 13 deletions

View File

@@ -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.
### `<send-message>`
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
<send-message channel="whatsapp" chat="5511999999999">Your transcription is ready!</send-message>
<send-message channel="telegram" chat="123456">Job #42 completed successfully.</send-message>
```
**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 ...]`).
### `<send-file>`
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
<send-file path="/tmp/report.pdf" caption="Report attached" />
<send-file path="/tmp/photo.png" kind="image" caption="Look!" />
<send-file path="/tmp/voice.ogg" kind="audio" cleanup="true" />
<send-file path="/tmp/temp-export.csv" cleanup="true" />
<send-file path="/tmp/result.txt" channel="whatsapp" chat="5511999999999" caption="Here's your file" />
```
**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`

View File

@@ -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).

View File

@@ -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(
'<actions><send-message channel="whatsapp" chat="5511999999999">Your transcription is ready</send-message></actions>',
);
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(
'<actions><send-message channel="telegram" chat="123">Done!</send-message></actions>\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(
'<actions><send-message channel="slack" chat="C123">Line one.\nLine two.</send-message></actions>',
);
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(
'<actions><send-message chat="123">Hello</send-message></actions>',
);
expect(result.directives).toEqual([]);
});
it('ignores send-message without chat attribute', () => {
const result = parseDirectives(
'<actions><send-message channel="telegram">Hello</send-message></actions>',
);
expect(result.directives).toEqual([]);
});
it('ignores send-message with empty text', () => {
const result = parseDirectives(
'<actions><send-message channel="telegram" chat="123"> </send-message></actions>',
);
expect(result.directives).toEqual([]);
});
it('parses multiple send-message directives', () => {
const result = parseDirectives(
'<actions>' +
'<send-message channel="whatsapp" chat="111">Hello user 1</send-message>' +
'<send-message channel="telegram" chat="222">Hello user 2</send-message>' +
'</actions>',
);
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(
'<actions>' +
'<react emoji="thumbsup" />' +
'<send-message channel="whatsapp" chat="555">Result ready</send-message>' +
'<send-file path="report.pdf" />' +
'</actions>',
);
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(
'<actions><send-file path="result.txt" channel="whatsapp" chat="5511999999999" caption="Here you go" /></actions>',
);
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(
'<actions><send-file path="report.pdf" caption="Report" /></actions>',
);
expect(result.directives).toEqual([
{ type: 'send-file', path: 'report.pdf', caption: 'Report' },
]);
});
});
describe('stripActionsBlock', () => {

View File

@@ -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*<actions>([\s\S]*?)<\/actions>/;
/**
* Match supported directive tags inside the actions block in source order.
* - Self-closing: <react ... />, <send-file ... />
* - Content-bearing: <voice>...</voice>
* - Content-bearing: <voice>...</voice>, <send-message ...>...</send-message>
*
* Groups:
* 1: self-closing tag name (react|send-file)
* 2: self-closing attribute string
* 3: <voice> text content
* 4: <send-message> attribute string
* 5: <send-message> text content
*/
const DIRECTIVE_TOKEN_REGEX = /<(react|send-file)\b([^>]*)\/>|<voice>([\s\S]*?)<\/voice>/g;
const DIRECTIVE_TOKEN_REGEX = /<(react|send-file)\b([^>]*)\/>|<voice>([\s\S]*?)<\/voice>|<send-message\b([^>]*)>([\s\S]*?)<\/send-message>/g;
/**
* Parse a single attribute string like: emoji="eyes" message="123"
@@ -76,13 +92,13 @@ function parseAttributes(attrString: string): Record<string, string> {
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 } : {}),
});
}
}