Add send-file directive and Discord/CLI file support (#319)

Co-authored-by: Jason Carreira <jason@visotrust.com>
Co-authored-by: Cameron <cameron@pfiffer.org>
Co-authored-by: Charles Packer <packercharles@gmail.com>
Co-authored-by: Sarah Wooders <sarahwooders@gmail.com>
Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Carreira
2026-02-23 18:44:34 -05:00
committed by GitHub
parent ad4c22ba54
commit 1fbd6d5a2e
14 changed files with 364 additions and 22 deletions

View File

@@ -10,6 +10,7 @@ Send a message to the most recent chat, or target a specific channel/chat.
```bash
lettabot-message send --text "Hello from a background task"
lettabot-message send --text "Hello" --channel slack --chat C123456
lettabot-message send --file /tmp/report.pdf --text "Report attached" --channel discord --chat 123456789
```
## lettabot-react
@@ -34,3 +35,4 @@ Notes:
- History fetch is not supported by the Telegram Bot API, Signal, or WhatsApp.
- If you omit `--channel` or `--chat`, the CLI falls back to the last message target stored in `lettabot-agent.json`.
- You need the channel-specific bot token set (`DISCORD_BOT_TOKEN` or `SLACK_BOT_TOKEN`).
- File sending uses the API server and requires `LETTABOT_API_KEY` (supported: telegram, slack, discord, whatsapp).

View File

@@ -455,6 +455,21 @@ Precedence: `prompt` (inline YAML) > `HEARTBEAT_PROMPT` (env var) > `promptFile`
| `features.heartbeat.prompt` | string | _(none)_ | Custom heartbeat prompt text |
| `features.heartbeat.promptFile` | string | _(none)_ | Path to prompt file (relative to working dir) |
### Send-File Directory
The `<send-file>` [response directive](./directives.md) allows the agent to send files to channels. For security, file paths are restricted to a configurable directory:
```yaml
features:
sendFileDir: ./data/outbound # Default: agent working directory
```
Only files inside this directory (and its subdirectories) can be sent. Paths that resolve outside it are blocked. This prevents prompt injection attacks from exfiltrating sensitive files.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `features.sendFileDir` | string | _(workingDir)_ | Directory that `<send-file>` paths must be inside |
### Cron Jobs
```yaml

View File

@@ -41,6 +41,28 @@ 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-file>`
Sends a file or image to the same channel/chat as the triggering message.
```xml
<send-file path="/tmp/report.pdf" caption="Report attached" />
<send-file path="/tmp/photo.png" kind="image" caption="Look!" />
<send-file path="/tmp/temp-export.csv" cleanup="true" />
```
**Attributes:**
- `path` / `file` (required) -- Local file path on the LettaBot server
- `caption` / `text` (optional) -- Caption text for the file
- `kind` (optional) -- `image` or `file` (defaults to auto-detect based on extension)
- `cleanup` (optional) -- `true` to delete the file after sending (default: false)
**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.
- Symlinks that resolve outside the allowed directory are also blocked.
- File size is limited to `sendFileMaxSize` (default: 50MB).
- The `cleanup` attribute only works when `sendFileCleanup: true` is set in the agent's features config (disabled by default).
### `<no-reply/>`
Suppresses response delivery entirely. The agent's text is discarded.
@@ -66,13 +88,13 @@ Backslash-escaped quotes (common when LLMs generate XML inside a JSON context) a
## Channel Support
| Channel | `addReaction` | Notes |
|-----------|:---:|-------|
| Telegram | Yes | Limited to Telegram's [allowed reaction set](https://core.telegram.org/bots/api#reactiontype) (~75 emoji) |
| Slack | Yes | Uses Slack emoji names (`:thumbsup:` style). Custom workspace emoji supported. |
| Discord | Yes | Unicode emoji and common aliases. Custom server emoji not yet supported. |
| WhatsApp | No | Directive is skipped with a warning |
| Signal | No | Directive is skipped with a warning |
| Channel | `addReaction` | `send-file` | Notes |
|-----------|:---:|:---:|-------|
| Telegram | Yes | Yes | Reactions limited to Telegram's [allowed reaction set](https://core.telegram.org/bots/api#reactiontype). |
| Slack | Yes | Yes | Reactions use Slack emoji names (`:thumbsup:` style). |
| Discord | Yes | Yes | Custom server emoji not yet supported. |
| WhatsApp | No | Yes | Reactions skipped with a warning. |
| Signal | No | No | Directive skipped with a warning. |
When a channel doesn't implement `addReaction`, the directive is silently skipped and a warning is logged. This never blocks message delivery.
@@ -112,7 +134,7 @@ The parser (`src/core/directives.ts`) is designed to be extensible. Adding a new
3. Add a parsing case in `parseChildDirectives()`
4. Add an execution case in `executeDirectives()` in `bot.ts`
See issue [#240](https://github.com/letta-ai/lettabot/issues/240) for planned directives like `<send-file>`.
See issue [#240](https://github.com/letta-ai/lettabot/issues/240) for planned directives.
## Source

View File

@@ -73,6 +73,9 @@ features:
enabled: false
intervalMin: 30
# skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables)
# sendFileDir: ./data/outbound # Restrict <send-file> directive to this directory (default: data/outbound)
# sendFileMaxSize: 52428800 # Max file size in bytes for <send-file> (default: 50MB)
# sendFileCleanup: false # Allow <send-file cleanup="true"> to delete files after send (default: false)
# memfs: true # Enable memory filesystem (git-backed context repository). Syncs memory blocks to local files.
# display:
# showToolCalls: false # Show tool invocations in chat (e.g. "Using tool: Read (file_path: ...)")

View File

@@ -12,6 +12,7 @@ import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
import { HELP_TEXT } from '../core/commands.js';
import { isGroupAllowed, isGroupUserAllowed, resolveGroupMode, resolveReceiveBotMessages, type GroupModeConfig } from './group-mode.js';
import { basename } from 'node:path';
// Dynamic import to avoid requiring Discord deps if not used
let Client: typeof import('discord.js').Client;
@@ -346,6 +347,23 @@ Ask the bot owner to approve with:
return { messageId: result.id };
}
async sendFile(file: OutboundFile): Promise<{ messageId: string }> {
if (!this.client) throw new Error('Discord not started');
const channel = await this.client.channels.fetch(file.chatId);
if (!channel || !channel.isTextBased() || !('send' in channel)) {
throw new Error(`Discord channel not found or not text-based: ${file.chatId}`);
}
const payload = {
content: file.caption || undefined,
files: [
{ attachment: file.filePath, name: basename(file.filePath) },
],
};
const result = await (channel as { send: (options: typeof payload) => Promise<{ id: string }> }).send(payload);
return { messageId: result.id };
}
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
if (!this.client) throw new Error('Discord not started');
const channel = await this.client.channels.fetch(chatId);
@@ -510,6 +528,7 @@ const DISCORD_EMOJI_ALIAS_TO_UNICODE: Record<string, string> = {
tada: '\u{1F389}',
clap: '\u{1F44F}',
ok_hand: '\u{1F44C}',
white_check_mark: '\u2705',
};
function resolveDiscordEmoji(input: string): string {

View File

@@ -252,6 +252,7 @@ async function sendCommand(args: string[]): Promise<void> {
let kind: 'image' | 'file' | undefined = undefined;
let channel = '';
let chatId = '';
const fileCapableChannels = new Set(['telegram', 'slack', 'discord', 'whatsapp']);
// Parse args
for (let i = 0; i < args.length; i++) {
@@ -304,16 +305,16 @@ async function sendCommand(args: string[]): Promise<void> {
}
try {
// Use API for WhatsApp (unified multipart endpoint)
if (channel === 'whatsapp') {
if (filePath) {
if (!fileCapableChannels.has(channel)) {
throw new Error(`File sending not supported for ${channel}. Supported: telegram, slack, discord, whatsapp`);
}
await sendViaApi(channel, chatId, { text, filePath, kind });
} else if (filePath) {
// Other channels with files - not yet implemented via API
throw new Error(`File sending for ${channel} requires API (currently only WhatsApp supported via API)`);
} else {
// Other channels with text only - direct API calls
await sendToChannel(channel, chatId, text);
return;
}
// Text-only: direct platform APIs (WhatsApp uses API internally)
await sendToChannel(channel, chatId, text);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
@@ -359,7 +360,8 @@ Environment variables:
LETTABOT_API_URL API server URL (default: http://localhost:8080)
SIGNAL_CLI_REST_API_URL Signal daemon URL (default: http://127.0.0.1:8090)
Note: WhatsApp uses the API server. Other channels use direct platform APIs.
Note: File sending uses the API server for supported channels (telegram, slack, discord, whatsapp).
Text-only messages use direct platform APIs (WhatsApp uses API).
`);
}

View File

@@ -77,6 +77,9 @@ export interface AgentConfig {
};
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
maxToolCalls?: number;
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete after send (default: false)
display?: DisplayConfig;
};
/** Polling config */
@@ -151,6 +154,9 @@ export interface LettaBotConfig {
inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths.
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100)
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete after send (default: false)
display?: DisplayConfig; // Show tool calls / reasoning in channel output
};

View File

@@ -6,6 +6,8 @@
import { createAgent, createSession, resumeSession, imageFromFile, imageFromURL, type Session, type MessageContentItem, type SendMessage, type CanUseToolCallback } from '@letta-ai/letta-code-sdk';
import { mkdirSync } from 'node:fs';
import { access, unlink, realpath, stat, constants } from 'node:fs/promises';
import { extname, resolve, join } from 'node:path';
import type { ChannelAdapter } from '../channels/types.js';
import type { BotConfig, InboundMessage, TriggerContext } from './types.js';
import type { AgentSession } from './interfaces.js';
@@ -102,6 +104,44 @@ const SUPPORTED_IMAGE_MIMES = new Set([
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
]);
const IMAGE_FILE_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff',
]);
/** Infer whether a file is an image or generic file based on extension. */
export function inferFileKind(filePath: string): 'image' | 'file' {
const ext = extname(filePath).toLowerCase();
return IMAGE_FILE_EXTENSIONS.has(ext) ? 'image' : 'file';
}
/**
* Check whether a resolved file path is inside the allowed directory.
* Prevents path traversal attacks in the send-file directive.
*
* Uses realpath() for both the file and directory to follow symlinks,
* preventing symlink-based escapes (e.g., data/evil -> /etc/passwd).
* Falls back to textual resolve() when paths don't exist on disk.
*/
export async function isPathAllowed(filePath: string, allowedDir: string): Promise<boolean> {
// Resolve the allowed directory -- use realpath if it exists, resolve() otherwise
let canonicalDir: string;
try {
canonicalDir = await realpath(allowedDir);
} catch {
canonicalDir = resolve(allowedDir);
}
// Resolve the file -- use realpath if it exists, resolve() otherwise
let canonicalFile: string;
try {
canonicalFile = await realpath(filePath);
} catch {
canonicalFile = resolve(filePath);
}
return canonicalFile === canonicalDir || canonicalFile.startsWith(canonicalDir + '/');
}
async function buildMultimodalMessage(
formattedText: string,
msg: InboundMessage,
@@ -406,6 +446,7 @@ export class LettaBot implements AgentSession {
adapter: ChannelAdapter,
chatId: string,
fallbackMessageId?: string,
threadId?: string,
): Promise<boolean> {
let acted = false;
for (const directive of directives) {
@@ -424,6 +465,68 @@ export class LettaBot implements AgentSession {
console.warn('[Bot] Directive react failed:', err instanceof Error ? err.message : err);
}
}
continue;
}
if (directive.type === 'send-file') {
if (typeof adapter.sendFile !== 'function') {
console.warn(`[Bot] Directive send-file skipped: ${adapter.name} does not support sendFile`);
continue;
}
// Path sandboxing: restrict to configured directory (default: data/outbound under workingDir)
const allowedDir = this.config.sendFileDir || join(this.config.workingDir, 'data', 'outbound');
const resolvedPath = resolve(directive.path);
if (!await isPathAllowed(resolvedPath, allowedDir)) {
console.warn(`[Bot] Directive send-file blocked: ${directive.path} is outside allowed directory ${allowedDir}`);
continue;
}
// Async file existence + readability check
try {
await access(resolvedPath, constants.R_OK);
} catch {
console.warn(`[Bot] Directive send-file skipped: file not found or not readable at ${directive.path}`);
continue;
}
// File size guard (default: 50MB)
const maxSize = this.config.sendFileMaxSize ?? 50 * 1024 * 1024;
try {
const fileStat = await stat(resolvedPath);
if (fileStat.size > maxSize) {
console.warn(`[Bot] Directive send-file blocked: ${directive.path} is ${fileStat.size} bytes (max: ${maxSize})`);
continue;
}
} catch {
console.warn(`[Bot] Directive send-file skipped: could not stat ${directive.path}`);
continue;
}
try {
await adapter.sendFile({
chatId,
filePath: resolvedPath,
caption: directive.caption,
kind: directive.kind ?? inferFileKind(resolvedPath),
threadId,
});
acted = true;
console.log(`[Bot] Directive: sent file ${resolvedPath}`);
// Optional cleanup: delete file after successful send.
// Only honored when sendFileCleanup is enabled in config (defense-in-depth).
if (directive.cleanup && this.config.sendFileCleanup) {
try {
await unlink(resolvedPath);
console.warn(`[Bot] Directive: cleaned up ${resolvedPath}`);
} catch (cleanupErr) {
console.warn('[Bot] Directive send-file cleanup failed:', cleanupErr instanceof Error ? cleanupErr.message : cleanupErr);
}
}
} catch (err) {
console.warn('[Bot] Directive send-file failed:', err instanceof Error ? err.message : err);
}
}
}
return acted;
@@ -1133,7 +1236,7 @@ export class LettaBot implements AgentSession {
if (response.trim()) {
const { cleanText, directives } = parseDirectives(response);
response = cleanText;
if (await this.executeDirectives(directives, adapter, msg.chatId, msg.messageId)) {
if (await this.executeDirectives(directives, adapter, msg.chatId, msg.messageId, msg.threadId)) {
sentAnyMessage = true;
}
}
@@ -1450,7 +1553,7 @@ export class LettaBot implements AgentSession {
if (response.trim()) {
const { cleanText, directives } = parseDirectives(response);
response = cleanText;
if (await this.executeDirectives(directives, adapter, msg.chatId, msg.messageId)) {
if (await this.executeDirectives(directives, adapter, msg.chatId, msg.messageId, msg.threadId)) {
sentAnyMessage = true;
}
}

View File

@@ -61,6 +61,44 @@ describe('parseDirectives', () => {
]);
});
it('parses send-file directive with path and caption', () => {
const result = parseDirectives('<actions><send-file path="/tmp/report.pdf" caption="Report" /></actions>');
expect(result.cleanText).toBe('');
expect(result.directives).toEqual([
{ type: 'send-file', path: '/tmp/report.pdf', caption: 'Report' },
]);
});
it('parses send-file directive with file alias and kind', () => {
const result = parseDirectives('<actions><send-file file="photo.png" kind="image" /></actions>');
expect(result.cleanText).toBe('');
expect(result.directives).toEqual([
{ type: 'send-file', path: 'photo.png', kind: 'image' },
]);
});
it('parses send-file directive with cleanup attribute', () => {
const result = parseDirectives('<actions><send-file path="/tmp/report.pdf" cleanup="true" /></actions>');
expect(result.cleanText).toBe('');
expect(result.directives).toEqual([
{ type: 'send-file', path: '/tmp/report.pdf', cleanup: true },
]);
});
it('omits cleanup when not set to true', () => {
const result = parseDirectives('<actions><send-file path="/tmp/report.pdf" cleanup="false" /></actions>');
expect(result.cleanText).toBe('');
expect(result.directives).toEqual([
{ type: 'send-file', path: '/tmp/report.pdf' },
]);
});
it('ignores send-file directive without path or file attribute', () => {
const result = parseDirectives('<actions><send-file caption="Missing" /></actions>');
expect(result.cleanText).toBe('');
expect(result.directives).toEqual([]);
});
it('ignores react directive without emoji attribute', () => {
const result = parseDirectives('<actions><react message="123" /></actions>');
expect(result.cleanText).toBe('');

View File

@@ -22,8 +22,16 @@ export interface ReactDirective {
messageId?: string;
}
export interface SendFileDirective {
type: 'send-file';
path: string;
caption?: string;
kind?: 'image' | 'file';
cleanup?: boolean;
}
// Union type — extend with more directive types later
export type Directive = ReactDirective;
export type Directive = ReactDirective | SendFileDirective;
export interface ParseResult {
cleanText: string;
@@ -40,7 +48,7 @@ const ACTIONS_BLOCK_REGEX = /^\s*<actions>([\s\S]*?)<\/actions>/;
* Match self-closing child directive tags inside the actions block.
* Captures the tag name and the full attributes string.
*/
const CHILD_DIRECTIVE_REGEX = /<(react)\b([^>]*)\/>/g;
const CHILD_DIRECTIVE_REGEX = /<(react|send-file)\b([^>]*)\/>/g;
/**
* Parse a single attribute string like: emoji="eyes" message="123"
@@ -79,6 +87,25 @@ function parseChildDirectives(block: string): Directive[] {
...(attrs.message ? { messageId: attrs.message } : {}),
});
}
continue;
}
if (tagName === 'send-file') {
const attrs = parseAttributes(attrString);
const path = attrs.path || attrs.file;
if (!path) continue;
const caption = attrs.caption || attrs.text;
const kind = attrs.kind === 'image' || attrs.kind === 'file'
? attrs.kind
: undefined;
const cleanup = attrs.cleanup === 'true';
directives.push({
type: 'send-file',
path,
...(caption ? { caption } : {}),
...(kind ? { kind } : {}),
...(cleanup ? { cleanup } : {}),
});
}
}

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { inferFileKind, isPathAllowed } from './bot.js';
import { mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
describe('inferFileKind', () => {
it('returns image for common image extensions', () => {
expect(inferFileKind('/tmp/photo.png')).toBe('image');
expect(inferFileKind('/tmp/photo.jpg')).toBe('image');
expect(inferFileKind('/tmp/photo.jpeg')).toBe('image');
expect(inferFileKind('/tmp/photo.gif')).toBe('image');
expect(inferFileKind('/tmp/photo.webp')).toBe('image');
expect(inferFileKind('/tmp/photo.bmp')).toBe('image');
expect(inferFileKind('/tmp/photo.tiff')).toBe('image');
});
it('returns file for non-image extensions', () => {
expect(inferFileKind('/tmp/report.pdf')).toBe('file');
expect(inferFileKind('/tmp/data.csv')).toBe('file');
expect(inferFileKind('/tmp/document.docx')).toBe('file');
expect(inferFileKind('/tmp/archive.zip')).toBe('file');
expect(inferFileKind('/tmp/script.ts')).toBe('file');
});
it('is case insensitive', () => {
expect(inferFileKind('/tmp/PHOTO.PNG')).toBe('image');
expect(inferFileKind('/tmp/photo.JPG')).toBe('image');
expect(inferFileKind('/tmp/photo.Jpeg')).toBe('image');
});
it('returns file for extensionless paths', () => {
expect(inferFileKind('/tmp/noext')).toBe('file');
});
});
describe('isPathAllowed', () => {
// These use non-existent paths, so isPathAllowed falls back to resolve() (textual check)
it('allows files inside the allowed directory', async () => {
expect(await isPathAllowed('/home/bot/data/report.pdf', '/home/bot/data')).toBe(true);
});
it('allows files in nested subdirectories', async () => {
expect(await isPathAllowed('/home/bot/data/sub/deep/file.txt', '/home/bot/data')).toBe(true);
});
it('blocks files outside the allowed directory', async () => {
expect(await isPathAllowed('/etc/passwd', '/home/bot/data')).toBe(false);
expect(await isPathAllowed('/home/bot/.env', '/home/bot/data')).toBe(false);
});
it('blocks path traversal attempts', async () => {
expect(await isPathAllowed('/home/bot/data/../.env', '/home/bot/data')).toBe(false);
expect(await isPathAllowed('/home/bot/data/../../etc/passwd', '/home/bot/data')).toBe(false);
});
it('allows the directory itself', async () => {
expect(await isPathAllowed('/home/bot/data', '/home/bot/data')).toBe(true);
});
it('blocks sibling directories with similar prefixes', async () => {
// /home/bot/data-evil should NOT be allowed when allowedDir is /home/bot/data
expect(await isPathAllowed('/home/bot/data-evil/secret.txt', '/home/bot/data')).toBe(false);
});
it('handles trailing slashes in allowed directory', async () => {
expect(await isPathAllowed('/home/bot/data/file.txt', '/home/bot/data/')).toBe(true);
});
// Symlink escape test: symlink inside allowed dir pointing outside
describe('symlink handling', () => {
const testDir = join(tmpdir(), 'lettabot-test-sendfile-' + Date.now());
const allowedDir = join(testDir, 'allowed');
const outsideFile = join(testDir, 'secret.txt');
const symlinkPath = join(allowedDir, 'evil-link');
beforeAll(() => {
mkdirSync(allowedDir, { recursive: true });
writeFileSync(outsideFile, 'secret content');
symlinkSync(outsideFile, symlinkPath);
});
afterAll(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('blocks symlinks that resolve outside the allowed directory', async () => {
// The symlink is inside allowedDir textually, but resolves to outsideFile
expect(await isPathAllowed(symlinkPath, allowedDir)).toBe(false);
});
it('allows real files inside the allowed directory', async () => {
const realFile = join(allowedDir, 'legit.txt');
writeFileSync(realFile, 'safe content');
expect(await isPathAllowed(realFile, allowedDir)).toBe(true);
});
});
});

View File

@@ -45,7 +45,7 @@ lettabot-react add --emoji :eyes:
# Add a reaction to a specific message
lettabot-react add --emoji :eyes: --channel telegram --chat 123456789 --message 987654321
# Note: File sending supported on telegram, slack, whatsapp (via API)
# Note: File sending supported on telegram, slack, discord, whatsapp (via API)
# Signal does not support files or reactions
# Discover channel IDs (Discord and Slack)
@@ -103,6 +103,7 @@ This sends "Great idea!" and reacts with thumbsup.
- \`<react emoji="eyes" />\` -- react to the message you are responding to. Emoji names (eyes, thumbsup, heart, fire, tada, clap) or unicode.
- \`<react emoji="fire" message="123" />\` -- react to a specific message by ID.
- \`<send-file path="/path/to/file.png" kind="image" caption="..." />\` -- send a file or image to the same channel/chat. File paths are restricted to the configured send-file directory (default: \`data/outbound/\` in the working directory). Paths outside this directory are blocked.
### Actions-only response

View File

@@ -145,6 +145,9 @@ export interface BotConfig {
// Security
allowedUsers?: string[]; // Empty = allow all
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
sendFileCleanup?: boolean; // Allow <send-file cleanup="true"> to delete files after send (default: false)
// Conversation routing
conversationMode?: 'shared' | 'per-channel'; // Default: shared

View File

@@ -586,6 +586,9 @@ async function main() {
disallowedTools: globalConfig.disallowedTools,
displayName: agentConfig.displayName,
maxToolCalls: agentConfig.features?.maxToolCalls,
sendFileDir: agentConfig.features?.sendFileDir,
sendFileMaxSize: agentConfig.features?.sendFileMaxSize,
sendFileCleanup: agentConfig.features?.sendFileCleanup,
memfs: resolvedMemfs,
display: agentConfig.features?.display,
conversationMode: agentConfig.conversations?.mode || 'shared',