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:
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user