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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: ...)")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
107
src/core/bot.ts
107
src/core/bot.ts
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
98
src/core/send-file.test.ts
Normal file
98
src/core/send-file.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user