merge: resolve conflict with main (multi-agent architecture)

- Keep multi-agent normalizeAgents() flow from main
- Integrate deprecation warning for agent.model from PR
- Remove model from LettaBot constructor (server-side property)
- Remove Model: display from single-agent startup log

Written by Cameron ◯ Letta Code

"The best interface is no interface." -- Golden Krishna
This commit is contained in:
Cameron
2026-02-09 10:52:32 -08:00
42 changed files with 3265 additions and 829 deletions

View File

@@ -69,10 +69,9 @@ jobs:
echo "## Install" >> notes.md
echo "" >> notes.md
echo '```bash' >> notes.md
echo "git clone https://github.com/letta-ai/lettabot.git" >> notes.md
echo "cd lettabot" >> notes.md
echo "git checkout ${CURRENT_TAG}" >> notes.md
echo "npm install && npm run build && npm link" >> notes.md
echo "npx lettabot onboard" >> notes.md
echo "# or" >> notes.md
echo "npm install -g lettabot" >> notes.md
echo '```' >> notes.md
# Add full changelog link
@@ -103,5 +102,21 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# TODO: Ping letta-code agent to write a richer summary once
# letta-code-action supports release events / custom prompts
- name: Publish to npm
if: env.NPM_TOKEN != ''
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
# Set version from git tag (strip 'v' prefix)
VERSION=${CURRENT_TAG#v}
npm version "$VERSION" --no-git-tag-version --allow-same-version
# Determine npm tag (pre-releases get 'next', stable gets 'latest')
if echo "$CURRENT_TAG" | grep -qE '(alpha|beta|rc)'; then
npm publish --tag next --access public
else
npm publish --access public
fi
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -237,6 +237,18 @@ The agent sees a clear `[SILENT MODE]` banner when triggered by heartbeats/cron,
If your agent isn't sending messages during heartbeats, check the [ADE](https://app.letta.com) to see what the agent is doing and whether it's attempting to use `lettabot-message`.
## Agent CLI Tools
LettaBot includes small CLIs the agent can invoke via Bash (or you can run directly):
```bash
lettabot-message send --text "Hello from a background task"
lettabot-react add --emoji :eyes: --channel discord --chat 123 --message 456
lettabot-history fetch --limit 25 --channel discord --chat 123456789
```
See [CLI Tools](docs/cli-tools.md) for details and limitations.
## Connect to Letta Code
Any LettaBot agent can also be directly chatted with through [Letta Code](https://github.com/letta-ai/letta-code). Use the `/status` command to find your `agent_id`, and run:
```sh

View File

@@ -8,6 +8,7 @@ LettaBot is a multi-channel AI assistant powered by [Letta](https://letta.com) t
- [Self-Hosted Setup](./selfhosted-setup.md) - Run with your own Letta server
- [Configuration Reference](./configuration.md) - All config options
- [Commands Reference](./commands.md) - Bot commands reference
- [CLI Tools](./cli-tools.md) - Agent/operator CLI tools
- [Scheduling Tasks](./cron-setup.md) - Cron jobs and heartbeats
- [Gmail Pub/Sub](./gmail-pubsub.md) - Email notifications integration
- [Railway Deployment](./railway-deploy.md) - Deploy to Railway

36
docs/cli-tools.md Normal file
View File

@@ -0,0 +1,36 @@
# CLI Tools
LettaBot ships with a few small CLIs that the agent can invoke via Bash, or you can run manually.
They use the same config/credentials as the bot server.
## lettabot-message
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-react
Add a reaction to a message (emoji can be unicode or :alias:).
```bash
lettabot-react add --emoji :eyes: --channel discord --chat 123 --message 456
lettabot-react add --emoji "👍"
```
## lettabot-history
Fetch recent messages from supported channels (Discord, Slack).
```bash
lettabot-history fetch --limit 25 --channel discord --chat 123456789
lettabot-history fetch --limit 10 --channel slack --chat C123456 --before 1712345678.000100
```
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`).

View File

@@ -24,7 +24,8 @@ server:
mode: cloud # 'cloud' or 'selfhosted'
apiKey: letta_... # Required for cloud mode
# Agent settings
# Agent settings (single agent mode)
# For multiple agents, use `agents:` array instead -- see Multi-Agent section
agent:
name: LettaBot
# id: agent-... # Optional: use existing agent
@@ -67,6 +68,14 @@ features:
enabled: true
intervalMin: 60
# Polling (background checks for Gmail, etc.)
polling:
enabled: true
intervalMs: 60000 # Check every 60 seconds
gmail:
enabled: true
account: user@example.com
# Voice transcription
transcription:
provider: openai
@@ -77,6 +86,12 @@ transcription:
attachments:
maxMB: 20
maxAgeDays: 14
# API server (health checks, CLI messaging)
api:
port: 8080 # Default: 8080 (or PORT env var)
# host: 0.0.0.0 # Uncomment for Docker/Railway
# corsOrigin: https://my.app # Uncomment for cross-origin access
```
## Server Configuration
@@ -103,7 +118,9 @@ docker run -v ~/.letta/.persist/pgdata:/var/lib/postgresql/data \
letta/letta:latest
```
## Agent Configuration
## Agent Configuration (Single Agent)
The default config uses `agent:` and `channels:` at the top level for a single agent:
| Option | Type | Description |
|--------|------|-------------|
@@ -114,6 +131,106 @@ docker run -v ~/.letta/.persist/pgdata:/var/lib/postgresql/data \
> Use `lettabot model show` to see the current model and `lettabot model set <handle>` to change it.
> During initial setup (`lettabot onboard`), you'll be prompted to select a model for new agents.
For multiple agents, see [Multi-Agent Configuration](#multi-agent-configuration) below.
## Multi-Agent Configuration
Run multiple independent agents from a single LettaBot instance. Each agent gets its own channels, state, cron, heartbeat, and polling services.
Use the `agents:` array instead of the top-level `agent:` and `channels:` keys:
```yaml
server:
mode: cloud
apiKey: letta_...
agents:
- name: work-assistant
model: claude-sonnet-4
# id: agent-abc123 # Optional: use existing agent
channels:
telegram:
token: ${WORK_TELEGRAM_TOKEN}
dmPolicy: pairing
slack:
botToken: ${SLACK_BOT_TOKEN}
appToken: ${SLACK_APP_TOKEN}
features:
cron: true
heartbeat:
enabled: true
intervalMin: 30
- name: personal-assistant
model: claude-sonnet-4
channels:
signal:
phone: "+1234567890"
selfChat: true
whatsapp:
enabled: true
selfChat: true
features:
heartbeat:
enabled: true
intervalMin: 60
```
### Per-Agent Options
Each entry in `agents:` accepts:
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `name` | string | Yes | Agent name (used for display, creation, and state isolation) |
| `id` | string | No | Use existing agent ID (skips creation) |
| `model` | string | No | Model for agent creation |
| `channels` | object | No | Channel configs (same schema as top-level `channels:`). At least one agent must have channels. |
| `features` | object | No | Per-agent features (cron, heartbeat, maxToolCalls) |
| `polling` | object | No | Per-agent polling config (Gmail, etc.) |
| `integrations` | object | No | Per-agent integrations (Google, etc.) |
### How it works
- Each agent is a separate Letta agent with its own conversation history and memory
- Agents have isolated state, channels, and services (see [known limitations](#known-limitations) for exceptions)
- The `LettaGateway` orchestrates startup, shutdown, and message delivery across agents
- Legacy single-agent configs (`agent:` + `channels:`) continue to work unchanged
### Migrating from single to multi-agent
Your existing config:
```yaml
agent:
name: MyBot
channels:
telegram:
token: "..."
features:
cron: true
```
Becomes:
```yaml
agents:
- name: MyBot
channels:
telegram:
token: "..."
features:
cron: true
```
The `server:`, `transcription:`, `attachments:`, and `api:` sections remain at the top level (shared across all agents).
### Known limitations
- Two agents cannot share the same channel type without ambiguous API routing ([#219](https://github.com/letta-ai/lettabot/issues/219))
- WhatsApp/Signal session paths are not yet agent-scoped ([#220](https://github.com/letta-ai/lettabot/issues/220))
- Heartbeat prompt and target are not yet configurable per-agent ([#221](https://github.com/letta-ai/lettabot/issues/221))
## Channel Configuration
All channels share these common options:
@@ -176,6 +293,43 @@ features:
Heartbeats are background tasks where the agent can review pending work.
#### Custom Heartbeat Prompt
You can customize what the agent is told during heartbeats. The custom text replaces the default body while keeping the silent mode envelope (time, trigger metadata, and messaging instructions).
Inline in YAML:
```yaml
features:
heartbeat:
enabled: true
intervalMin: 60
prompt: "Check your todo list and work on the highest priority item."
```
From a file (re-read each tick, so edits take effect without restart):
```yaml
features:
heartbeat:
enabled: true
intervalMin: 60
promptFile: ./prompts/heartbeat.md
```
Via environment variable:
```bash
HEARTBEAT_PROMPT="Review recent conversations" npm start
```
Precedence: `prompt` (inline YAML) > `HEARTBEAT_PROMPT` (env var) > `promptFile` (file) > built-in default.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `features.heartbeat.prompt` | string | _(none)_ | Custom heartbeat prompt text |
| `features.heartbeat.promptFile` | string | _(none)_ | Path to prompt file (relative to working dir) |
### Cron Jobs
```yaml
@@ -185,6 +339,67 @@ features:
Enable scheduled tasks. See [Cron Setup](./cron-setup.md).
### No-Reply (Opt-Out)
The agent can choose not to respond to a message by sending exactly:
```
<no-reply/>
```
When the bot receives this marker, it suppresses the response and nothing is sent to the channel. This is useful in group chats where the agent shouldn't reply to every message.
The agent is taught about this behavior in two places:
- **System prompt**: A "Choosing Not to Reply" section explains when to use it (messages not directed at the agent, simple acknowledgments, conversations between other users, etc.)
- **Message envelope**: Group messages include a hint reminding the agent of the `<no-reply/>` option. DMs do not include this hint.
The bot also handles this gracefully during streaming -- it holds back partial output while the response could still become `<no-reply/>`, so users never see a partial match leak through.
## Polling Configuration
Background polling for integrations like Gmail. Runs independently of agent cron jobs.
```yaml
polling:
enabled: true # Master switch (default: auto-detected from sub-configs)
intervalMs: 60000 # Check every 60 seconds (default: 60000)
gmail:
enabled: true
account: user@example.com # Gmail account to poll
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `polling.enabled` | boolean | auto | Master switch. Defaults to `true` if any sub-config is enabled |
| `polling.intervalMs` | number | `60000` | Polling interval in milliseconds |
| `polling.gmail.enabled` | boolean | auto | Enable Gmail polling. Auto-detected from `account` |
| `polling.gmail.account` | string | - | Gmail account to poll for unread messages |
### Legacy config path
For backward compatibility, Gmail polling can also be configured under `integrations.google`:
```yaml
integrations:
google:
enabled: true
account: user@example.com
pollIntervalSec: 60
```
The top-level `polling` section takes priority if both are present.
### Environment variable fallback
| Env Variable | Polling Config Equivalent |
|--------------|--------------------------|
| `GMAIL_ACCOUNT` | `polling.gmail.account` |
| `POLLING_INTERVAL_MS` | `polling.intervalMs` |
| `PORT` | `api.port` |
| `API_HOST` | `api.host` |
| `API_CORS_ORIGIN` | `api.corsOrigin` |
## Transcription Configuration
Voice message transcription via OpenAI Whisper:
@@ -206,6 +421,23 @@ attachments:
Attachments are stored in `/tmp/lettabot/attachments/`.
## API Server Configuration
The built-in API server provides health checks and CLI messaging endpoints.
```yaml
api:
port: 9090 # Default: 8080
host: 0.0.0.0 # Default: 127.0.0.1 (localhost only)
corsOrigin: "*" # Default: same-origin only
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `api.port` | number | `8080` | Port for the API/health server |
| `api.host` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` for Docker/Railway |
| `api.corsOrigin` | string | _(none)_ | CORS origin header for cross-origin access |
## Environment Variables
Environment variables override config file values:
@@ -226,5 +458,7 @@ Environment variables override config file values:
| `WHATSAPP_SELF_CHAT_MODE` | `channels.whatsapp.selfChat` |
| `SIGNAL_PHONE_NUMBER` | `channels.signal.phone` |
| `OPENAI_API_KEY` | `transcription.apiKey` |
| `GMAIL_ACCOUNT` | `polling.gmail.account` |
| `POLLING_INTERVAL_MS` | `polling.intervalMs` |
See [SKILL.md](../SKILL.md) for complete environment variable reference.

View File

@@ -137,6 +137,10 @@ The bot can receive and process:
Attachments are downloaded to `/tmp/lettabot/attachments/telegram/` and the agent can view images using its Read tool.
### Long Messages
Telegram limits messages to 4096 characters. LettaBot automatically splits longer responses into multiple messages at paragraph or line boundaries, so no content is lost.
### Reactions
LettaBot can react to messages using the `lettabot-react` CLI:

View File

@@ -1,6 +1,6 @@
{
"name": "lettabot",
"version": "1.0.0",
"version": "0.2.0",
"type": "module",
"main": "dist/main.js",
"bin": {
@@ -8,13 +8,15 @@
"lettabot-schedule": "./dist/cron/cli.js",
"lettabot-message": "./dist/cli/message.js",
"lettabot-react": "./dist/cli/react.js",
"lettabot-history": "./dist/cli/history.js",
"lettabot-channels": "./dist/cli/channels.js"
},
"scripts": {
"setup": "tsx src/setup.ts",
"dev": "tsx src/main.ts",
"build": "tsc",
"postinstall": "npx patch-package",
"prepare": "npx patch-package || true",
"prepublishOnly": "npm run build && npm run test:run",
"start": "node dist/main.js",
"test": "vitest",
"test:run": "vitest run",
@@ -31,15 +33,33 @@
"skills:find": "npx skills find"
},
"keywords": [
"telegram",
"bot",
"letta",
"ai",
"agent"
"agent",
"chatbot",
"telegram",
"slack",
"whatsapp",
"signal",
"discord",
"multi-agent"
],
"author": "",
"author": "Letta <team@letta.com>",
"license": "Apache-2.0",
"description": "Multi-channel AI assistant with persistent memory - Telegram, Slack, WhatsApp",
"description": "Multi-channel AI assistant with persistent memory - Telegram, Slack, WhatsApp, Signal, Discord",
"repository": {
"type": "git",
"url": "https://github.com/letta-ai/lettabot.git"
},
"homepage": "https://github.com/letta-ai/lettabot",
"engines": {
"node": ">=20"
},
"files": [
"dist/",
".skills/",
"patches/"
],
"dependencies": {
"@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1",

View File

@@ -8,7 +8,7 @@ import * as fs from 'fs';
import { validateApiKey } from './auth.js';
import type { SendMessageRequest, SendMessageResponse, SendFileResponse } from './types.js';
import { parseMultipart } from './multipart.js';
import type { LettaBot } from '../core/bot.js';
import type { MessageDeliverer } from '../core/interfaces.js';
import type { ChannelId } from '../core/types.js';
const VALID_CHANNELS: ChannelId[] = ['telegram', 'slack', 'discord', 'whatsapp', 'signal'];
@@ -26,7 +26,7 @@ interface ServerOptions {
/**
* Create and start the HTTP API server
*/
export function createApiServer(bot: LettaBot, options: ServerOptions): http.Server {
export function createApiServer(deliverer: MessageDeliverer, options: ServerOptions): http.Server {
const server = http.createServer(async (req, res) => {
// Set CORS headers (configurable origin, defaults to same-origin for security)
const corsOrigin = options.corsOrigin || req.headers.origin || 'null';
@@ -87,8 +87,8 @@ export function createApiServer(bot: LettaBot, options: ServerOptions): http.Ser
const file = files.length > 0 ? files[0] : undefined;
// Send via unified bot method
const messageId = await bot.deliverToChannel(
// Send via unified deliverer method
const messageId = await deliverer.deliverToChannel(
fields.channel as ChannelId,
fields.chatId,
{

View File

@@ -177,33 +177,36 @@ Ask the bot owner to approve with:
}
}
const access = await this.checkAccess(userId);
if (access === 'blocked') {
const ch = message.channel;
if (ch.isTextBased() && 'send' in ch) {
await (ch as { send: (content: string) => Promise<unknown> }).send(
"Sorry, you're not authorized to use this bot."
);
}
return;
}
if (access === 'pairing') {
const { code, created } = await upsertPairingRequest('discord', userId, {
username: message.author.username,
});
if (!code) {
await message.channel.send('Too many pending pairing requests. Please try again later.');
// Bypass pairing for guild (group) messages
if (!message.guildId) {
const access = await this.checkAccess(userId);
if (access === 'blocked') {
const ch = message.channel;
if (ch.isTextBased() && 'send' in ch) {
await (ch as { send: (content: string) => Promise<unknown> }).send(
"Sorry, you're not authorized to use this bot."
);
}
return;
}
if (created) {
console.log(`[Discord] New pairing request from ${userId} (${message.author.username}): ${code}`);
}
if (access === 'pairing') {
const { code, created } = await upsertPairingRequest('discord', userId, {
username: message.author.username,
});
await this.sendPairingMessage(message, this.formatPairingMsg(code));
return;
if (!code) {
await message.channel.send('Too many pending pairing requests. Please try again later.');
return;
}
if (created) {
console.log(`[Discord] New pairing request from ${userId} (${message.author.username}): ${code}`);
}
await this.sendPairingMessage(message, this.formatPairingMsg(code));
return;
}
}
const attachments = await this.collectAttachments(message.attachments, message.channel.id);
@@ -237,6 +240,7 @@ Ask the bot owner to approve with:
const isGroup = !!message.guildId;
const groupName = isGroup && 'name' in message.channel ? message.channel.name : undefined;
const displayName = message.member?.displayName || message.author.globalName || message.author.username;
const wasMentioned = isGroup && !!this.client?.user && message.mentions.has(this.client.user);
await this.onMessage({
channel: 'discord',
@@ -249,6 +253,8 @@ Ask the bot owner to approve with:
timestamp: message.createdAt,
isGroup,
groupName,
serverId: message.guildId || undefined,
wasMentioned,
attachments,
});
}
@@ -318,6 +324,10 @@ Ask the bot owner to approve with:
}
}
getDmPolicy(): string {
return this.config.dmPolicy || 'pairing';
}
supportsEditing(): boolean {
return true;
}
@@ -375,6 +385,7 @@ Ask the bot owner to approve with:
timestamp: new Date(),
isGroup,
groupName,
serverId: message.guildId || undefined,
reaction: {
emoji,
messageId: message.id,

View File

@@ -303,6 +303,10 @@ This code expires in 1 hour.`;
};
}
getDmPolicy(): string {
return this.config.dmPolicy || 'pairing';
}
supportsEditing(): boolean {
return false;
}
@@ -595,24 +599,49 @@ This code expires in 1 hour.`;
const voiceAttachment = attachments?.find(a => a.contentType?.startsWith('audio/'));
if (voiceAttachment?.id) {
console.log(`[Signal] Voice attachment detected: ${voiceAttachment.contentType}, id: ${voiceAttachment.id}`);
// Always persist voice audio to attachments directory
let savedAudioPath: string | undefined;
const signalAttDir = join(homedir(), '.local/share/signal-cli/attachments');
const voiceSourcePath = join(signalAttDir, voiceAttachment.id);
if (this.config.attachmentsDir) {
const rawExt = voiceAttachment.contentType?.split('/')[1] || 'ogg';
// Clean extension: "aac" not "aac.aac" (filename may already have extension)
const ext = rawExt.replace(/;.*$/, ''); // strip codec params like "ogg;codecs=opus"
const voiceFileName = `voice-${voiceAttachment.id}.${ext}`;
const voiceTargetPath = buildAttachmentPath(this.config.attachmentsDir, 'signal', chatId, voiceFileName);
try {
const voiceFileReady = await waitForFile(voiceSourcePath, 5000);
if (voiceFileReady) {
await copyFile(voiceSourcePath, voiceTargetPath);
savedAudioPath = voiceTargetPath;
console.log(`[Signal] Voice audio saved to ${voiceTargetPath}`);
}
} catch (err) {
console.warn('[Signal] Failed to save voice audio:', err);
}
}
try {
const { loadConfig } = await import('../config/index.js');
const config = loadConfig();
if (!config.transcription?.apiKey && !process.env.OPENAI_API_KEY) {
if (chatId) {
const audioInfo = savedAudioPath ? ` Audio saved to: ${savedAudioPath}` : '';
await this.sendMessage({
chatId,
text: 'Voice messages require OpenAI API key for transcription. See: https://github.com/letta-ai/lettabot#voice-messages'
text: `Voice messages require OpenAI API key for transcription.${audioInfo} See: https://github.com/letta-ai/lettabot#voice-messages`
});
}
} else {
// Read attachment from signal-cli attachments directory
// Note: signal-cli may still be downloading when SSE event fires, so we wait
const { readFileSync } = await import('node:fs');
const { homedir } = await import('node:os');
const { join } = await import('node:path');
const { homedir: hd } = await import('node:os');
const { join: pjoin } = await import('node:path');
const attachmentPath = join(homedir(), '.local/share/signal-cli/attachments', voiceAttachment.id);
const attachmentPath = pjoin(hd(), '.local/share/signal-cli/attachments', voiceAttachment.id);
console.log(`[Signal] Waiting for attachment: ${attachmentPath}`);
// Wait for file to be available (signal-cli may still be downloading)
@@ -630,26 +659,26 @@ This code expires in 1 hour.`;
const ext = voiceAttachment.contentType?.split('/')[1] || 'ogg';
const result = await transcribeAudio(buffer, `voice.${ext}`, { audioPath: attachmentPath });
const audioRef = savedAudioPath ? ` (audio: ${savedAudioPath})` : '';
if (result.success) {
if (result.text) {
console.log(`[Signal] Transcribed voice message: "${result.text.slice(0, 50)}..."`);
messageText = (messageText ? messageText + '\n' : '') + `[Voice message]: ${result.text}`;
} else {
console.warn(`[Signal] Transcription returned empty text`);
messageText = (messageText ? messageText + '\n' : '') + `[Voice message - transcription returned empty]`;
messageText = (messageText ? messageText + '\n' : '') + `[Voice message - transcription returned empty${audioRef}]`;
}
} else {
const errorMsg = result.error || 'Unknown transcription error';
console.error(`[Signal] Transcription failed: ${errorMsg}`);
const errorInfo = result.audioPath
? `[Voice message - transcription failed: ${errorMsg}. Audio saved to: ${result.audioPath}]`
: `[Voice message - transcription failed: ${errorMsg}]`;
messageText = (messageText ? messageText + '\n' : '') + errorInfo;
messageText = (messageText ? messageText + '\n' : '') + `[Voice message - transcription failed: ${errorMsg}${audioRef}]`;
}
}
} catch (error) {
console.error('[Signal] Error transcribing voice message:', error);
messageText = (messageText ? messageText + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}]`;
const audioRef = savedAudioPath ? ` Audio saved to: ${savedAudioPath}` : '';
messageText = (messageText ? messageText + '\n' : '') + `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}.${audioRef}]`;
}
} else if (attachments?.some(a => a.contentType?.startsWith('audio/'))) {
// Audio attachment exists but has no ID
@@ -679,8 +708,11 @@ This code expires in 1 hour.`;
}
// selfChatMode enabled - allow the message through
console.log('[Signal] Note to Self allowed (selfChatMode enabled)');
} else if (chatId.startsWith('group:')) {
// Group messages bypass pairing - anyone in the group can interact
console.log('[Signal] Group message - bypassing access control');
} else {
// External message - check access control
// External DM - check access control
console.log('[Signal] Checking access for external message');
const access = await this.checkAccess(source);
console.log(`[Signal] Access result: ${access}`);

View File

@@ -17,6 +17,7 @@ let App: typeof import('@slack/bolt').App;
export interface SlackConfig {
botToken: string; // xoxb-...
appToken: string; // xapp-... (for Socket Mode)
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[]; // Slack user IDs (e.g., U01234567)
attachmentsDir?: string;
attachmentsMaxBytes?: number;
@@ -139,11 +140,12 @@ export class SlackAdapter implements ChannelAdapter {
threadId: threadTs,
isGroup,
groupName: isGroup ? channelId : undefined, // Would need conversations.info for name
wasMentioned: false, // Regular messages; app_mention handles mentions
attachments,
});
}
});
// Handle app mentions (@bot)
this.app.event('app_mention', async ({ event }) => {
const userId = event.user || '';
@@ -189,6 +191,7 @@ export class SlackAdapter implements ChannelAdapter {
threadId: threadTs,
isGroup,
groupName: isGroup ? channelId : undefined,
wasMentioned: true, // app_mention is always a mention
attachments,
});
}
@@ -274,6 +277,10 @@ export class SlackAdapter implements ChannelAdapter {
});
}
getDmPolicy(): string {
return this.config.dmPolicy || 'pairing';
}
async sendTypingIndicator(_chatId: string): Promise<void> {
// Slack doesn't have a typing indicator API for bots
// This is a no-op

View File

@@ -14,6 +14,7 @@ import {
upsertPairingRequest,
formatPairingMessage,
} from '../pairing/store.js';
import { isGroupApproved, approveGroup } from '../pairing/group-store.js';
import { basename } from 'node:path';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
@@ -79,17 +80,68 @@ export class TelegramAdapter implements ChannelAdapter {
}
private setupHandlers(): void {
// Middleware: Check access based on dmPolicy
// Detect when bot is added/removed from groups (proactive group gating)
this.bot.on('my_chat_member', async (ctx) => {
const chatMember = ctx.myChatMember;
if (!chatMember) return;
const chatType = chatMember.chat.type;
if (chatType !== 'group' && chatType !== 'supergroup') return;
const newStatus = chatMember.new_chat_member.status;
if (newStatus !== 'member' && newStatus !== 'administrator') return;
const chatId = String(chatMember.chat.id);
const fromId = String(chatMember.from.id);
const dmPolicy = this.config.dmPolicy || 'pairing';
// No gating when policy is not pairing
if (dmPolicy !== 'pairing') {
await approveGroup('telegram', chatId);
console.log(`[Telegram] Group ${chatId} auto-approved (dmPolicy=${dmPolicy})`);
return;
}
// Check if the user who added the bot is paired
const configAllowlist = this.config.allowedUsers?.map(String);
const allowed = await isUserAllowed('telegram', fromId, configAllowlist);
if (allowed) {
await approveGroup('telegram', chatId);
console.log(`[Telegram] Group ${chatId} approved by paired user ${fromId}`);
} else {
console.log(`[Telegram] Unpaired user ${fromId} tried to add bot to group ${chatId}, leaving`);
try {
await ctx.api.sendMessage(chatId, 'This bot can only be added to groups by paired users.');
await ctx.api.leaveChat(chatId);
} catch (err) {
console.error('[Telegram] Failed to leave group:', err);
}
}
});
// Middleware: Check access based on dmPolicy (bypass for groups)
this.bot.use(async (ctx, next) => {
const userId = ctx.from?.id;
if (!userId) return;
// Group gating: check if group is approved before processing
const chatType = ctx.chat?.type;
if (chatType === 'group' || chatType === 'supergroup') {
const dmPolicy = this.config.dmPolicy || 'pairing';
if (dmPolicy === 'open' || await isGroupApproved('telegram', String(ctx.chat!.id))) {
await next();
}
// Silently drop messages from unapproved groups
return;
}
const access = await this.checkAccess(
String(userId),
ctx.from?.username,
ctx.from?.first_name
);
if (access === 'allowed') {
await next();
return;
@@ -158,19 +210,49 @@ export class TelegramAdapter implements ChannelAdapter {
const userId = ctx.from?.id;
const chatId = ctx.chat.id;
const text = ctx.message.text;
if (!userId) return;
if (text.startsWith('/')) return; // Skip other commands
// Group detection
const chatType = ctx.chat.type;
const isGroup = chatType === 'group' || chatType === 'supergroup';
const groupName = isGroup && 'title' in ctx.chat ? ctx.chat.title : undefined;
// Mention detection for groups
let wasMentioned = false;
if (isGroup) {
const botUsername = this.bot.botInfo?.username;
if (botUsername) {
// Check entities for bot_command or mention matching our username
const entities = ctx.message.entities || [];
wasMentioned = entities.some((e) => {
if (e.type === 'mention') {
const mentioned = text.substring(e.offset, e.offset + e.length);
return mentioned.toLowerCase() === `@${botUsername.toLowerCase()}`;
}
return false;
});
// Fallback: text-based check
if (!wasMentioned) {
wasMentioned = text.toLowerCase().includes(`@${botUsername.toLowerCase()}`);
}
}
}
if (this.onMessage) {
await this.onMessage({
channel: 'telegram',
chatId: String(chatId),
userId: String(userId),
userName: ctx.from.username || ctx.from.first_name,
userHandle: ctx.from.username,
messageId: String(ctx.message.message_id),
text,
timestamp: new Date(),
isGroup,
groupName,
wasMentioned,
});
}
});
@@ -360,24 +442,48 @@ export class TelegramAdapter implements ChannelAdapter {
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
const { markdownToTelegramV2 } = await import('./telegram-format.js');
// Try MarkdownV2 first
try {
const formatted = await markdownToTelegramV2(msg.text);
const result = await this.bot.api.sendMessage(msg.chatId, formatted, {
parse_mode: 'MarkdownV2',
reply_to_message_id: msg.replyToMessageId ? Number(msg.replyToMessageId) : undefined,
});
return { messageId: String(result.message_id) };
} catch (e) {
// If MarkdownV2 fails, send raw text with notice
console.warn('[Telegram] MarkdownV2 send failed, falling back to raw text:', e);
const errorMsg = e instanceof Error ? e.message : String(e);
const fallbackText = `${msg.text}\n\n(Telegram formatting failed: ${errorMsg.slice(0, 50)}. Report: github.com/letta-ai/lettabot/issues)`;
const result = await this.bot.api.sendMessage(msg.chatId, fallbackText, {
reply_to_message_id: msg.replyToMessageId ? Number(msg.replyToMessageId) : undefined,
});
return { messageId: String(result.message_id) };
// Split long messages into chunks (Telegram limit: 4096 chars)
const chunks = splitMessageText(msg.text);
let lastMessageId = '';
for (const chunk of chunks) {
// Only first chunk replies to the original message
const replyId = !lastMessageId && msg.replyToMessageId ? Number(msg.replyToMessageId) : undefined;
// Try MarkdownV2 first
try {
const formatted = await markdownToTelegramV2(chunk);
// MarkdownV2 escaping can expand text beyond 4096 - re-split if needed
if (formatted.length > TELEGRAM_MAX_LENGTH) {
const subChunks = splitFormattedText(formatted);
for (const sub of subChunks) {
const result = await this.bot.api.sendMessage(msg.chatId, sub, {
parse_mode: 'MarkdownV2',
reply_to_message_id: replyId,
});
lastMessageId = String(result.message_id);
}
} else {
const result = await this.bot.api.sendMessage(msg.chatId, formatted, {
parse_mode: 'MarkdownV2',
reply_to_message_id: replyId,
});
lastMessageId = String(result.message_id);
}
} catch (e) {
// If MarkdownV2 fails, send raw text (also split if needed)
console.warn('[Telegram] MarkdownV2 send failed, falling back to raw text:', e);
const plainChunks = splitFormattedText(chunk);
for (const plain of plainChunks) {
const result = await this.bot.api.sendMessage(msg.chatId, plain, {
reply_to_message_id: replyId,
});
lastMessageId = String(result.message_id);
}
}
}
return { messageId: lastMessageId };
}
async sendFile(file: OutboundFile): Promise<{ messageId: string }> {
@@ -395,8 +501,14 @@ export class TelegramAdapter implements ChannelAdapter {
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
const { markdownToTelegramV2 } = await import('./telegram-format.js');
const formatted = await markdownToTelegramV2(text);
await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' });
try {
const formatted = await markdownToTelegramV2(text);
await this.bot.api.editMessageText(chatId, Number(messageId), formatted, { parse_mode: 'MarkdownV2' });
} catch (e) {
// If MarkdownV2 fails, fall back to plain text (mirrors sendMessage fallback)
console.warn('[Telegram] MarkdownV2 edit failed, falling back to raw text:', e);
await this.bot.api.editMessageText(chatId, Number(messageId), text);
}
}
async addReaction(chatId: string, messageId: string, emoji: string): Promise<void> {
@@ -409,6 +521,10 @@ export class TelegramAdapter implements ChannelAdapter {
]);
}
getDmPolicy(): string {
return this.config.dmPolicy || 'pairing';
}
async sendTypingIndicator(chatId: string): Promise<void> {
await this.bot.api.sendChatAction(chatId, 'typing');
}
@@ -620,3 +736,86 @@ const TELEGRAM_REACTION_EMOJIS = [
type TelegramReactionEmoji = typeof TELEGRAM_REACTION_EMOJIS[number];
const TELEGRAM_REACTION_SET = new Set<string>(TELEGRAM_REACTION_EMOJIS);
// Telegram message length limit
const TELEGRAM_MAX_LENGTH = 4096;
// Leave room for MarkdownV2 escaping overhead when splitting raw text
const TELEGRAM_SPLIT_THRESHOLD = 3800;
/**
* Split raw markdown text into chunks that will fit within Telegram's limit
* after MarkdownV2 formatting. Splits at paragraph boundaries (double newlines),
* falling back to single newlines, then hard-splitting at the threshold.
*/
function splitMessageText(text: string): string[] {
if (text.length <= TELEGRAM_SPLIT_THRESHOLD) {
return [text];
}
const chunks: string[] = [];
let remaining = text;
while (remaining.length > TELEGRAM_SPLIT_THRESHOLD) {
let splitIdx = -1;
// Try paragraph boundary (double newline)
const searchRegion = remaining.slice(0, TELEGRAM_SPLIT_THRESHOLD);
const lastParagraph = searchRegion.lastIndexOf('\n\n');
if (lastParagraph > TELEGRAM_SPLIT_THRESHOLD * 0.3) {
splitIdx = lastParagraph;
}
// Fall back to single newline
if (splitIdx === -1) {
const lastNewline = searchRegion.lastIndexOf('\n');
if (lastNewline > TELEGRAM_SPLIT_THRESHOLD * 0.3) {
splitIdx = lastNewline;
}
}
// Hard split as last resort
if (splitIdx === -1) {
splitIdx = TELEGRAM_SPLIT_THRESHOLD;
}
chunks.push(remaining.slice(0, splitIdx).trimEnd());
remaining = remaining.slice(splitIdx).trimStart();
}
if (remaining.trim()) {
chunks.push(remaining.trim());
}
return chunks;
}
/**
* Split already-formatted text (MarkdownV2 or plain) at the hard 4096 limit.
* Used as a safety net when formatting expands text beyond the limit.
* Tries to split at newlines to avoid breaking mid-word.
*/
function splitFormattedText(text: string): string[] {
if (text.length <= TELEGRAM_MAX_LENGTH) {
return [text];
}
const chunks: string[] = [];
let remaining = text;
while (remaining.length > TELEGRAM_MAX_LENGTH) {
const searchRegion = remaining.slice(0, TELEGRAM_MAX_LENGTH);
let splitIdx = searchRegion.lastIndexOf('\n');
if (splitIdx < TELEGRAM_MAX_LENGTH * 0.3) {
// No good newline found - hard split
splitIdx = TELEGRAM_MAX_LENGTH;
}
chunks.push(remaining.slice(0, splitIdx));
remaining = remaining.slice(splitIdx).replace(/^\n/, '');
}
if (remaining) {
chunks.push(remaining);
}
return chunks;
}

View File

@@ -26,6 +26,7 @@ export interface ChannelAdapter {
// Capabilities (optional)
supportsEditing?(): boolean;
sendFile?(file: OutboundFile): Promise<{ messageId: string }>;
getDmPolicy?(): string;
// Event handlers (set by bot core)
onMessage?: (msg: InboundMessage) => Promise<void>;

View File

@@ -977,6 +977,10 @@ export class WhatsAppAdapter implements ChannelAdapter {
);
}
getDmPolicy(): string {
return this.config.dmPolicy || 'pairing';
}
supportsEditing(): boolean {
return false;
}

View File

@@ -0,0 +1,123 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fetchDiscordHistory, fetchHistory, fetchSlackHistory, isValidLimit, parseFetchArgs } from './history-core.js';
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
for (const key of Object.keys(process.env)) {
delete process.env[key];
}
Object.assign(process.env, ORIGINAL_ENV);
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('parseFetchArgs', () => {
it('parses fetch args with flags', () => {
const parsed = parseFetchArgs([
'--limit', '25',
'--channel', 'discord',
'--chat', '123',
'--before', '456',
]);
expect(parsed).toEqual({
channel: 'discord',
chatId: '123',
before: '456',
limit: 25,
});
});
});
describe('isValidLimit', () => {
it('accepts positive integers only', () => {
expect(isValidLimit(1)).toBe(true);
expect(isValidLimit(50)).toBe(true);
expect(isValidLimit(0)).toBe(false);
expect(isValidLimit(-1)).toBe(false);
expect(isValidLimit(1.5)).toBe(false);
expect(isValidLimit(Number.NaN)).toBe(false);
});
});
// loadLastTarget is now backed by the Store class (handles v1/v2 transparently).
// Store-level tests in src/core/store.test.ts cover lastMessageTarget persistence.
describe('fetchDiscordHistory', () => {
it('formats Discord history responses', async () => {
process.env.DISCORD_BOT_TOKEN = 'test-token';
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ([
{
id: '111',
content: 'Hello',
author: { username: 'alice', globalName: 'Alice' },
timestamp: '2026-01-01T00:00:00Z',
},
]),
});
vi.stubGlobal('fetch', fetchSpy);
const output = await fetchDiscordHistory('999', 10, '888');
const parsed = JSON.parse(output) as {
count: number;
messages: Array<{ messageId: string; author: string; content: string; timestamp?: string }>;
};
expect(fetchSpy).toHaveBeenCalledWith(
'https://discord.com/api/v10/channels/999/messages?limit=10&before=888',
expect.objectContaining({ method: 'GET' })
);
expect(parsed.count).toBe(1);
expect(parsed.messages[0]).toEqual({
messageId: '111',
author: 'Alice',
content: 'Hello',
timestamp: '2026-01-01T00:00:00Z',
});
});
});
describe('fetchSlackHistory', () => {
it('formats Slack history responses', async () => {
process.env.SLACK_BOT_TOKEN = 'test-token';
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
ok: true,
messages: [
{
ts: '1704067200.000100',
text: 'Hello from Slack',
user: 'U123456',
},
],
}),
});
vi.stubGlobal('fetch', fetchSpy);
const output = await fetchSlackHistory('C999', 10);
const parsed = JSON.parse(output);
expect(fetchSpy).toHaveBeenCalledWith(
'https://slack.com/api/conversations.history',
expect.objectContaining({ method: 'POST' })
);
expect(parsed.count).toBe(1);
expect(parsed.messages[0].author).toBe('U123456');
expect(parsed.messages[0].content).toBe('Hello from Slack');
expect(parsed.messages[0].messageId).toBe('1704067200.000100');
// Verify ts -> ISO conversion
expect(parsed.messages[0].timestamp).toBeDefined();
});
});
describe('fetchHistory', () => {
it('rejects unsupported channels', async () => {
await expect(fetchHistory('unknown', '1', 1)).rejects.toThrow('Unknown channel');
});
});

146
src/cli/history-core.ts Normal file
View File

@@ -0,0 +1,146 @@
import type { LastTarget } from './shared.js';
export const DEFAULT_LIMIT = 50;
export function isValidLimit(limit: number): boolean {
return Number.isInteger(limit) && limit > 0;
}
export function parseFetchArgs(args: string[]): {
channel?: string;
chatId?: string;
before?: string;
limit: number;
} {
let channel = '';
let chatId = '';
let before = '';
let limit = DEFAULT_LIMIT;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
if ((arg === '--limit' || arg === '-l') && next) {
limit = Number(next);
i++;
} else if ((arg === '--channel' || arg === '-c') && next) {
channel = next;
i++;
} else if ((arg === '--chat' || arg === '--to') && next) {
chatId = next;
i++;
} else if ((arg === '--before' || arg === '-b') && next) {
before = next;
i++;
}
}
return {
channel: channel || undefined,
chatId: chatId || undefined,
before: before || undefined,
limit,
};
}
export async function fetchDiscordHistory(chatId: string, limit: number, before?: string): Promise<string> {
limit = Math.min(limit, 100);
const token = process.env.DISCORD_BOT_TOKEN;
if (!token) {
throw new Error('DISCORD_BOT_TOKEN not set');
}
const params = new URLSearchParams({ limit: String(limit) });
if (before) params.set('before', before);
const response = await fetch(`https://discord.com/api/v10/channels/${chatId}/messages?${params.toString()}`, {
method: 'GET',
headers: {
'Authorization': `Bot ${token}`,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Discord API error: ${error}`);
}
const messages = await response.json() as Array<{
id: string;
content: string;
author?: { username?: string; globalName?: string };
timestamp?: string;
}>;
const output = {
count: messages.length,
messages: messages.map((msg) => ({
messageId: msg.id,
author: msg.author?.globalName || msg.author?.username || 'unknown',
content: msg.content || '',
timestamp: msg.timestamp,
})),
};
return JSON.stringify(output, null, 2);
}
export async function fetchSlackHistory(chatId: string, limit: number, before?: string): Promise<string> {
limit = Math.min(limit, 1000);
const token = process.env.SLACK_BOT_TOKEN;
if (!token) {
throw new Error('SLACK_BOT_TOKEN not set');
}
const response = await fetch('https://slack.com/api/conversations.history', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
channel: chatId,
limit,
...(before ? { latest: before, inclusive: false } : {}),
}),
});
const result = await response.json() as {
ok: boolean;
error?: string;
messages?: Array<{ ts?: string; text?: string; user?: string; bot_id?: string }>;
};
if (!result.ok) {
throw new Error(`Slack API error: ${result.error || 'unknown error'}`);
}
const output = {
count: result.messages?.length || 0,
messages: (result.messages || []).map((msg) => ({
messageId: msg.ts,
author: msg.user || msg.bot_id || 'unknown',
content: msg.text || '',
timestamp: msg.ts ? new Date(Number(msg.ts) * 1000).toISOString() : undefined,
})),
};
return JSON.stringify(output, null, 2);
}
export async function fetchHistory(channel: string, chatId: string, limit: number, before?: string): Promise<string> {
switch (channel.toLowerCase()) {
case 'discord':
return fetchDiscordHistory(chatId, limit, before);
case 'slack':
return fetchSlackHistory(chatId, limit, before);
case 'telegram':
throw new Error('Telegram history fetch is not supported by the Bot API');
case 'signal':
throw new Error('Signal history fetch is not supported');
case 'whatsapp':
throw new Error('WhatsApp history fetch is not supported');
default:
throw new Error(`Unknown channel: ${channel}. Supported: discord, slack`);
}
}

100
src/cli/history.ts Normal file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* lettabot-history - Fetch message history from channels
*
* Usage:
* lettabot-history fetch --limit 50 [--channel discord] [--chat 123456] [--before 789]
*/
// Config loaded from lettabot.yaml
import { loadConfig, applyConfigToEnv } from '../config/index.js';
const config = loadConfig();
applyConfigToEnv(config);
import { fetchHistory, isValidLimit, parseFetchArgs } from './history-core.js';
import { loadLastTarget } from './shared.js';
async function fetchCommand(args: string[]): Promise<void> {
const parsed = parseFetchArgs(args);
let channel = parsed.channel || '';
let chatId = parsed.chatId || '';
const before = parsed.before || '';
const limit = parsed.limit;
if (!isValidLimit(limit)) {
console.error('Error: --limit must be a positive integer');
console.error('Usage: lettabot-history fetch --limit 50 [--channel discord] [--chat 123456] [--before 789]');
process.exit(1);
}
if (!channel || !chatId) {
const lastTarget = loadLastTarget();
if (lastTarget) {
channel = channel || lastTarget.channel;
chatId = chatId || lastTarget.chatId;
}
}
if (!channel) {
console.error('Error: --channel is required (no default available)');
console.error('Specify: --channel discord|slack');
process.exit(1);
}
if (!chatId) {
console.error('Error: --chat is required (no default available)');
console.error('Specify: --chat <chat_id>');
process.exit(1);
}
try {
const output = await fetchHistory(channel, chatId, limit, before || undefined);
console.log(output);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
function showHelp(): void {
console.log(`
lettabot-history - Fetch message history from channels
Commands:
fetch [options] Fetch recent messages
Fetch options:
--limit, -l <n> Max messages (default: 50)
--channel, -c <name> Channel: discord, slack
--chat, --to <id> Chat/conversation ID (default: last messaged)
--before, -b <id> Fetch messages before this message ID
Examples:
lettabot-history fetch --limit 50
lettabot-history fetch --limit 50 --channel discord --chat 123456789
lettabot-history fetch --limit 50 --before 987654321
`);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === 'help' || command === '--help' || command === '-h') {
showHelp();
return;
}
if (command === 'fetch') {
await fetchCommand(args.slice(1));
return;
}
console.error(`Unknown command: ${command}`);
showHelp();
process.exit(1);
}
main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});

View File

@@ -14,35 +14,8 @@
import { loadConfig, applyConfigToEnv } from '../config/index.js';
const config = loadConfig();
applyConfigToEnv(config);
import { resolve } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { getDataDir } from '../utils/paths.js';
// Types
interface LastTarget {
channel: string;
chatId: string;
}
interface AgentStore {
agentId?: string;
lastMessageTarget?: LastTarget; // Note: field is "lastMessageTarget" not "lastTarget"
}
// Store path (same location as bot uses)
const STORE_PATH = resolve(getDataDir(), 'lettabot-agent.json');
function loadLastTarget(): LastTarget | null {
try {
if (existsSync(STORE_PATH)) {
const store: AgentStore = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
return store.lastMessageTarget || null;
}
} catch {
// Ignore
}
return null;
}
import { loadLastTarget } from './shared.js';
// Channel senders
async function sendTelegram(chatId: string, text: string): Promise<void> {

View File

@@ -13,34 +13,7 @@
import { loadConfig, applyConfigToEnv } from '../config/index.js';
const config = loadConfig();
applyConfigToEnv(config);
import { resolve } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { getDataDir } from '../utils/paths.js';
interface LastTarget {
channel: string;
chatId: string;
messageId?: string;
}
interface AgentStore {
agentId?: string;
lastMessageTarget?: LastTarget;
}
const STORE_PATH = resolve(getDataDir(), 'lettabot-agent.json');
function loadLastTarget(): LastTarget | null {
try {
if (existsSync(STORE_PATH)) {
const store: AgentStore = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
return store.lastMessageTarget || null;
}
} catch {
// Ignore
}
return null;
}
import { loadLastTarget } from './shared.js';
const EMOJI_ALIAS_TO_UNICODE: Record<string, string> = {
eyes: '👀',

16
src/cli/shared.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Store } from '../core/store.js';
export interface LastTarget {
channel: string;
chatId: string;
messageId?: string;
}
/**
* Load the last message target from the agent store.
* Uses Store class which handles both v1 and v2 formats transparently.
*/
export function loadLastTarget(): LastTarget | null {
const store = new Store('lettabot-agent.json');
return store.lastMessageTarget || null;
}

View File

@@ -58,7 +58,12 @@ export function loadConfig(): LettaBotConfig {
try {
const content = readFileSync(configPath, 'utf-8');
const parsed = YAML.parse(content) as Partial<LettaBotConfig>;
// Fix instantGroups: YAML parses large numeric IDs (e.g. Discord snowflakes)
// as JavaScript numbers, losing precision for values > Number.MAX_SAFE_INTEGER.
// Re-extract from document AST to preserve the original string representation.
fixInstantGroupIds(content, parsed);
// Merge with defaults
return {
...DEFAULT_CONFIG,
@@ -132,6 +137,15 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
if (config.channels.slack?.botToken) {
env.SLACK_BOT_TOKEN = config.channels.slack.botToken;
}
if (config.channels.slack?.dmPolicy) {
env.SLACK_DM_POLICY = config.channels.slack.dmPolicy;
}
if (config.channels.slack?.groupPollIntervalMin !== undefined) {
env.SLACK_GROUP_POLL_INTERVAL_MIN = String(config.channels.slack.groupPollIntervalMin);
}
if (config.channels.slack?.instantGroups?.length) {
env.SLACK_INSTANT_GROUPS = config.channels.slack.instantGroups.join(',');
}
if (config.channels.whatsapp?.enabled) {
env.WHATSAPP_ENABLED = 'true';
if (config.channels.whatsapp.selfChat) {
@@ -140,6 +154,12 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
env.WHATSAPP_SELF_CHAT_MODE = 'false';
}
}
if (config.channels.whatsapp?.groupPollIntervalMin !== undefined) {
env.WHATSAPP_GROUP_POLL_INTERVAL_MIN = String(config.channels.whatsapp.groupPollIntervalMin);
}
if (config.channels.whatsapp?.instantGroups?.length) {
env.WHATSAPP_INSTANT_GROUPS = config.channels.whatsapp.instantGroups.join(',');
}
if (config.channels.signal?.phone) {
env.SIGNAL_PHONE_NUMBER = config.channels.signal.phone;
// Signal selfChat defaults to true, so only set env if explicitly false
@@ -147,6 +167,18 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
env.SIGNAL_SELF_CHAT_MODE = 'false';
}
}
if (config.channels.signal?.groupPollIntervalMin !== undefined) {
env.SIGNAL_GROUP_POLL_INTERVAL_MIN = String(config.channels.signal.groupPollIntervalMin);
}
if (config.channels.signal?.instantGroups?.length) {
env.SIGNAL_INSTANT_GROUPS = config.channels.signal.instantGroups.join(',');
}
if (config.channels.telegram?.groupPollIntervalMin !== undefined) {
env.TELEGRAM_GROUP_POLL_INTERVAL_MIN = String(config.channels.telegram.groupPollIntervalMin);
}
if (config.channels.telegram?.instantGroups?.length) {
env.TELEGRAM_INSTANT_GROUPS = config.channels.telegram.instantGroups.join(',');
}
if (config.channels.discord?.token) {
env.DISCORD_BOT_TOKEN = config.channels.discord.token;
if (config.channels.discord.dmPolicy) {
@@ -156,6 +188,12 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
env.DISCORD_ALLOWED_USERS = config.channels.discord.allowedUsers.join(',');
}
}
if (config.channels.discord?.groupPollIntervalMin !== undefined) {
env.DISCORD_GROUP_POLL_INTERVAL_MIN = String(config.channels.discord.groupPollIntervalMin);
}
if (config.channels.discord?.instantGroups?.length) {
env.DISCORD_INSTANT_GROUPS = config.channels.discord.instantGroups.join(',');
}
// Features
if (config.features?.cron) {
@@ -164,15 +202,26 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
if (config.features?.heartbeat?.enabled) {
env.HEARTBEAT_INTERVAL_MIN = String(config.features.heartbeat.intervalMin || 30);
}
if (config.features?.inlineImages === false) {
env.INLINE_IMAGES = 'false';
}
if (config.features?.maxToolCalls !== undefined) {
env.MAX_TOOL_CALLS = String(config.features.maxToolCalls);
}
// Integrations - Google (Gmail polling)
if (config.integrations?.google?.enabled && config.integrations.google.account) {
// Polling - top-level polling config (preferred)
if (config.polling?.gmail?.enabled && config.polling.gmail.account) {
env.GMAIL_ACCOUNT = config.polling.gmail.account;
}
if (config.polling?.intervalMs) {
env.POLLING_INTERVAL_MS = String(config.polling.intervalMs);
}
// Integrations - Google (legacy path for Gmail polling, lower priority)
if (!env.GMAIL_ACCOUNT && config.integrations?.google?.enabled && config.integrations.google.account) {
env.GMAIL_ACCOUNT = config.integrations.google.account;
}
if (config.integrations?.google?.pollIntervalSec) {
if (!env.POLLING_INTERVAL_MS && config.integrations?.google?.pollIntervalSec) {
env.POLLING_INTERVAL_MS = String(config.integrations.google.pollIntervalSec * 1000);
}
@@ -182,6 +231,17 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
if (config.attachments?.maxAgeDays !== undefined) {
env.ATTACHMENTS_MAX_AGE_DAYS = String(config.attachments.maxAgeDays);
}
// API server
if (config.api?.port !== undefined) {
env.PORT = String(config.api.port);
}
if (config.api?.host) {
env.API_HOST = config.api.host;
}
if (config.api?.corsOrigin) {
env.API_CORS_ORIGIN = config.api.corsOrigin;
}
return env;
}
@@ -261,3 +321,49 @@ export async function syncProviders(config: LettaBotConfig): Promise<void> {
}
}
}
/**
* Fix instantGroups arrays that may contain large numeric IDs parsed by YAML.
* Discord snowflake IDs exceed Number.MAX_SAFE_INTEGER, so YAML parses them
* as lossy JavaScript numbers. We re-read from the document AST to get the
* original string representation.
*/
function fixInstantGroupIds(yamlContent: string, parsed: Partial<LettaBotConfig>): void {
if (!parsed.channels) return;
try {
const doc = YAML.parseDocument(yamlContent);
const channels = ['telegram', 'slack', 'whatsapp', 'signal', 'discord'] as const;
for (const ch of channels) {
const seq = doc.getIn(['channels', ch, 'instantGroups'], true);
if (YAML.isSeq(seq)) {
const fixed = seq.items.map((item: unknown) => {
if (YAML.isScalar(item)) {
// For numbers, use the original source text to avoid precision loss
if (typeof item.value === 'number' && item.source) {
return item.source;
}
return String(item.value);
}
return String(item);
});
const cfg = parsed.channels[ch];
if (cfg) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(cfg as any).instantGroups = fixed;
}
}
}
} catch {
// Fallback: just ensure entries are strings (won't fix precision, but safe)
const channels = ['telegram', 'slack', 'whatsapp', 'signal', 'discord'] as const;
for (const ch of channels) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cfg = parsed.channels?.[ch] as any;
if (cfg && Array.isArray(cfg.instantGroups)) {
cfg.instantGroups = cfg.instantGroups.map((v: unknown) => String(v));
}
}
}
}

View File

@@ -0,0 +1,335 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { normalizeAgents, type LettaBotConfig, type AgentConfig } from './types.js';
describe('normalizeAgents', () => {
it('should normalize legacy single-agent config to one-entry array', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: {
name: 'TestBot',
model: 'anthropic/claude-sonnet-4',
},
channels: {
telegram: {
enabled: true,
token: 'test-token',
},
},
};
const agents = normalizeAgents(config);
expect(agents).toHaveLength(1);
expect(agents[0].name).toBe('TestBot');
expect(agents[0].model).toBe('anthropic/claude-sonnet-4');
expect(agents[0].channels.telegram?.token).toBe('test-token');
});
it('should drop channels with enabled: false', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {
telegram: {
enabled: true,
token: 'test-token',
},
slack: {
enabled: false,
botToken: 'should-be-dropped',
},
},
};
const agents = normalizeAgents(config);
expect(agents[0].channels.telegram).toBeDefined();
expect(agents[0].channels.slack).toBeUndefined();
});
it('should normalize multi-agent config channels', () => {
const agentsArray: AgentConfig[] = [
{
name: 'Bot1',
channels: {
telegram: { enabled: true, token: 'token1' },
slack: { enabled: true, botToken: 'missing-app-token' },
},
},
{
name: 'Bot2',
channels: {
slack: { enabled: true, botToken: 'token2', appToken: 'app2' },
discord: { enabled: false, token: 'disabled' },
},
},
];
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agents: agentsArray,
// Legacy fields (ignored when agents[] is present)
agent: { name: 'Unused', model: 'unused' },
channels: {},
};
const agents = normalizeAgents(config);
expect(agents).toHaveLength(2);
expect(agents[0].channels.telegram?.token).toBe('token1');
expect(agents[0].channels.slack).toBeUndefined();
expect(agents[1].channels.slack?.botToken).toBe('token2');
expect(agents[1].channels.discord).toBeUndefined();
});
it('should produce empty channels object when no channels configured', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {},
};
const agents = normalizeAgents(config);
expect(agents[0].channels).toEqual({});
});
it('should default agent name to "LettaBot" when not provided', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: '', model: '' }, // Empty name should fall back to 'LettaBot'
channels: {},
};
// Override with empty name to test default
const agents = normalizeAgents({
...config,
agent: undefined as any, // Test fallback when agent is missing
});
expect(agents[0].name).toBe('LettaBot');
});
it('should drop channels without required credentials', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {
telegram: {
enabled: true,
// Missing token
},
slack: {
enabled: true,
botToken: 'has-bot-token-only',
// Missing appToken
},
signal: {
enabled: true,
// Missing phone
},
discord: {
enabled: true,
// Missing token
},
},
};
const agents = normalizeAgents(config);
expect(agents[0].channels).toEqual({});
});
it('should preserve agent id when provided', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: {
id: 'agent-123',
name: 'TestBot',
model: 'test',
},
channels: {},
};
const agents = normalizeAgents(config);
expect(agents[0].id).toBe('agent-123');
});
describe('env var fallback (container deploys)', () => {
const envVars = [
'TELEGRAM_BOT_TOKEN', 'TELEGRAM_DM_POLICY', 'TELEGRAM_ALLOWED_USERS',
'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_DM_POLICY', 'SLACK_ALLOWED_USERS',
'WHATSAPP_ENABLED', 'WHATSAPP_SELF_CHAT_MODE', 'WHATSAPP_DM_POLICY', 'WHATSAPP_ALLOWED_USERS',
'SIGNAL_PHONE_NUMBER', 'SIGNAL_SELF_CHAT_MODE', 'SIGNAL_DM_POLICY', 'SIGNAL_ALLOWED_USERS',
'DISCORD_BOT_TOKEN', 'DISCORD_DM_POLICY', 'DISCORD_ALLOWED_USERS',
];
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
for (const key of envVars) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of envVars) {
if (savedEnv[key] !== undefined) {
process.env[key] = savedEnv[key];
} else {
delete process.env[key];
}
}
});
it('should pick up channels from env vars when YAML has none', () => {
process.env.TELEGRAM_BOT_TOKEN = 'env-telegram-token';
process.env.DISCORD_BOT_TOKEN = 'env-discord-token';
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {},
};
const agents = normalizeAgents(config);
expect(agents[0].channels.telegram?.token).toBe('env-telegram-token');
expect(agents[0].channels.discord?.token).toBe('env-discord-token');
});
it('should not override YAML channels with env vars', () => {
process.env.TELEGRAM_BOT_TOKEN = 'env-token';
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {
telegram: { enabled: true, token: 'yaml-token' },
},
};
const agents = normalizeAgents(config);
expect(agents[0].channels.telegram?.token).toBe('yaml-token');
});
it('should not apply env vars in multi-agent mode', () => {
process.env.TELEGRAM_BOT_TOKEN = 'env-token';
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agents: [{ name: 'Bot1', channels: {} }],
agent: { name: 'Unused', model: 'unused' },
channels: {},
};
const agents = normalizeAgents(config);
expect(agents[0].channels.telegram).toBeUndefined();
});
it('should pick up all channel types from env vars', () => {
process.env.TELEGRAM_BOT_TOKEN = 'tg-token';
process.env.SLACK_BOT_TOKEN = 'slack-bot';
process.env.SLACK_APP_TOKEN = 'slack-app';
process.env.WHATSAPP_ENABLED = 'true';
process.env.SIGNAL_PHONE_NUMBER = '+1234567890';
process.env.DISCORD_BOT_TOKEN = 'discord-token';
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {},
};
const agents = normalizeAgents(config);
expect(agents[0].channels.telegram?.token).toBe('tg-token');
expect(agents[0].channels.slack?.botToken).toBe('slack-bot');
expect(agents[0].channels.slack?.appToken).toBe('slack-app');
expect(agents[0].channels.whatsapp?.enabled).toBe(true);
expect(agents[0].channels.signal?.phone).toBe('+1234567890');
expect(agents[0].channels.discord?.token).toBe('discord-token');
});
it('should pick up allowedUsers from env vars for all channels', () => {
process.env.TELEGRAM_BOT_TOKEN = 'tg-token';
process.env.TELEGRAM_DM_POLICY = 'allowlist';
process.env.TELEGRAM_ALLOWED_USERS = '515978553, 123456';
process.env.SLACK_BOT_TOKEN = 'slack-bot';
process.env.SLACK_APP_TOKEN = 'slack-app';
process.env.SLACK_DM_POLICY = 'allowlist';
process.env.SLACK_ALLOWED_USERS = 'U123,U456';
process.env.DISCORD_BOT_TOKEN = 'discord-token';
process.env.DISCORD_DM_POLICY = 'allowlist';
process.env.DISCORD_ALLOWED_USERS = '999888777';
process.env.WHATSAPP_ENABLED = 'true';
process.env.WHATSAPP_DM_POLICY = 'allowlist';
process.env.WHATSAPP_ALLOWED_USERS = '+1234567890,+0987654321';
process.env.SIGNAL_PHONE_NUMBER = '+1555000000';
process.env.SIGNAL_DM_POLICY = 'allowlist';
process.env.SIGNAL_ALLOWED_USERS = '+1555111111';
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {},
};
const agents = normalizeAgents(config);
expect(agents[0].channels.telegram?.dmPolicy).toBe('allowlist');
expect(agents[0].channels.telegram?.allowedUsers).toEqual(['515978553', '123456']);
expect(agents[0].channels.slack?.dmPolicy).toBe('allowlist');
expect(agents[0].channels.slack?.allowedUsers).toEqual(['U123', 'U456']);
expect(agents[0].channels.discord?.dmPolicy).toBe('allowlist');
expect(agents[0].channels.discord?.allowedUsers).toEqual(['999888777']);
expect(agents[0].channels.whatsapp?.dmPolicy).toBe('allowlist');
expect(agents[0].channels.whatsapp?.allowedUsers).toEqual(['+1234567890', '+0987654321']);
expect(agents[0].channels.signal?.dmPolicy).toBe('allowlist');
expect(agents[0].channels.signal?.allowedUsers).toEqual(['+1555111111']);
});
});
it('should preserve features, polling, and integrations', () => {
const config: LettaBotConfig = {
server: { mode: 'cloud' },
agent: { name: 'TestBot', model: 'test' },
channels: {},
features: {
cron: true,
heartbeat: {
enabled: true,
intervalMin: 10,
},
maxToolCalls: 50,
},
polling: {
enabled: true,
intervalMs: 30000,
},
integrations: {
google: {
enabled: true,
account: 'test@example.com',
},
},
};
const agents = normalizeAgents(config);
expect(agents[0].features).toEqual(config.features);
expect(agents[0].polling).toEqual(config.polling);
expect(agents[0].integrations).toEqual(config.integrations);
});
});

View File

@@ -6,6 +6,44 @@
* 2. Letta Cloud: Uses apiKey, optional BYOK providers
*/
/**
* Configuration for a single agent in multi-agent mode.
* Each agent has its own name, channels, and features.
*/
export interface AgentConfig {
/** Agent name (used for display, agent creation, and store keying) */
name: string;
/** Use existing agent ID (skip creation) */
id?: string;
/** Model for initial agent creation */
model?: string;
/** Channels this agent connects to */
channels: {
telegram?: TelegramConfig;
slack?: SlackConfig;
whatsapp?: WhatsAppConfig;
signal?: SignalConfig;
discord?: DiscordConfig;
};
/** Features for this agent */
features?: {
cron?: boolean;
heartbeat?: {
enabled: boolean;
intervalMin?: number;
prompt?: string; // Custom heartbeat prompt (replaces default body)
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
};
maxToolCalls?: number;
};
/** Polling config */
polling?: PollingYamlConfig;
/** Integrations */
integrations?: {
google?: GoogleConfig;
};
}
export interface LettaBotConfig {
// Server connection
server: {
@@ -17,6 +55,9 @@ export interface LettaBotConfig {
apiKey?: string;
};
// Multi-agent configuration
agents?: AgentConfig[];
// Agent configuration
agent: {
id?: string;
@@ -44,11 +85,19 @@ export interface LettaBotConfig {
heartbeat?: {
enabled: boolean;
intervalMin?: number;
prompt?: string; // Custom heartbeat prompt (replaces default body)
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
};
inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths.
maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100)
};
// Polling - system-level background checks (Gmail, etc.)
polling?: PollingYamlConfig;
// Integrations (Google Workspace, etc.)
// NOTE: integrations.google is a legacy path for polling config.
// Prefer the top-level `polling` section instead.
integrations?: {
google?: GoogleConfig;
};
@@ -61,6 +110,13 @@ export interface LettaBotConfig {
maxMB?: number;
maxAgeDays?: number;
};
// API server (health checks, CLI messaging)
api?: {
port?: number; // Default: 8080 (or PORT env var)
host?: string; // Default: 127.0.0.1 (secure). Use '0.0.0.0' for Docker/Railway
corsOrigin?: string; // CORS origin. Default: same-origin only
};
}
export interface TranscriptionConfig {
@@ -69,6 +125,15 @@ export interface TranscriptionConfig {
model?: string; // Defaults to 'whisper-1'
}
export interface PollingYamlConfig {
enabled?: boolean; // Master switch (default: auto-detected from sub-configs)
intervalMs?: number; // Polling interval in milliseconds (default: 60000)
gmail?: {
enabled?: boolean; // Enable Gmail polling
account?: string; // Gmail account to poll (e.g., user@example.com)
};
}
export interface ProviderConfig {
id: string; // e.g., 'anthropic', 'openai'
name: string; // e.g., 'lc-anthropic'
@@ -81,13 +146,18 @@ export interface TelegramConfig {
token?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate)
instantGroups?: string[]; // Group chat IDs that bypass batching
}
export interface SlackConfig {
enabled: boolean;
appToken?: string;
botToken?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate)
instantGroups?: string[]; // Channel IDs that bypass batching
}
export interface WhatsAppConfig {
@@ -99,6 +169,8 @@ export interface WhatsAppConfig {
groupAllowFrom?: string[];
mentionPatterns?: string[];
groups?: Record<string, { requireMention?: boolean }>;
groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate)
instantGroups?: string[]; // Group JIDs that bypass batching
}
export interface SignalConfig {
@@ -110,6 +182,8 @@ export interface SignalConfig {
// Group gating
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@bot"])
groups?: Record<string, { requireMention?: boolean }>; // Per-group settings, "*" for defaults
groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate)
instantGroups?: string[]; // Group IDs that bypass batching
}
export interface DiscordConfig {
@@ -117,6 +191,8 @@ export interface DiscordConfig {
token?: string;
dmPolicy?: 'pairing' | 'allowlist' | 'open';
allowedUsers?: string[];
groupPollIntervalMin?: number; // Batch interval in minutes (default: 10, 0 = immediate)
instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching
}
export interface GoogleConfig {
@@ -137,3 +213,110 @@ export const DEFAULT_CONFIG: LettaBotConfig = {
},
channels: {},
};
/**
* Normalize config to multi-agent format.
*
* If the config uses legacy single-agent format (agent: + channels:),
* it's converted to an agents[] array with one entry.
* Channels with `enabled: false` are dropped during normalization.
*/
export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
const normalizeChannels = (channels?: AgentConfig['channels']): AgentConfig['channels'] => {
const normalized: AgentConfig['channels'] = {};
if (!channels) return normalized;
if (channels.telegram?.enabled !== false && channels.telegram?.token) {
normalized.telegram = channels.telegram;
}
if (channels.slack?.enabled !== false && channels.slack?.botToken && channels.slack?.appToken) {
normalized.slack = channels.slack;
}
// WhatsApp has no credential to check (uses QR pairing), so just check enabled
if (channels.whatsapp?.enabled) {
normalized.whatsapp = channels.whatsapp;
}
if (channels.signal?.enabled !== false && channels.signal?.phone) {
normalized.signal = channels.signal;
}
if (channels.discord?.enabled !== false && channels.discord?.token) {
normalized.discord = channels.discord;
}
return normalized;
};
// Multi-agent mode: normalize channels for each configured agent
if (config.agents && config.agents.length > 0) {
return config.agents.map(agent => ({
...agent,
channels: normalizeChannels(agent.channels),
}));
}
// Legacy single-agent mode: normalize to agents[]
const agentName = config.agent?.name || 'LettaBot';
const model = config.agent?.model;
const id = config.agent?.id;
// Filter out disabled/misconfigured channels
const channels = normalizeChannels(config.channels);
// Env var fallback for container deploys without lettabot.yaml (e.g. Railway)
// Helper: parse comma-separated env var into string array (or undefined)
const parseList = (envVar?: string): string[] | undefined =>
envVar ? envVar.split(',').map(s => s.trim()).filter(Boolean) : undefined;
if (!channels.telegram && process.env.TELEGRAM_BOT_TOKEN) {
channels.telegram = {
enabled: true,
token: process.env.TELEGRAM_BOT_TOKEN,
dmPolicy: (process.env.TELEGRAM_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.TELEGRAM_ALLOWED_USERS),
};
}
if (!channels.slack && process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
channels.slack = {
enabled: true,
botToken: process.env.SLACK_BOT_TOKEN,
appToken: process.env.SLACK_APP_TOKEN,
dmPolicy: (process.env.SLACK_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.SLACK_ALLOWED_USERS),
};
}
if (!channels.whatsapp && process.env.WHATSAPP_ENABLED === 'true') {
channels.whatsapp = {
enabled: true,
selfChat: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false',
dmPolicy: (process.env.WHATSAPP_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.WHATSAPP_ALLOWED_USERS),
};
}
if (!channels.signal && process.env.SIGNAL_PHONE_NUMBER) {
channels.signal = {
enabled: true,
phone: process.env.SIGNAL_PHONE_NUMBER,
selfChat: process.env.SIGNAL_SELF_CHAT_MODE !== 'false',
dmPolicy: (process.env.SIGNAL_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.SIGNAL_ALLOWED_USERS),
};
}
if (!channels.discord && process.env.DISCORD_BOT_TOKEN) {
channels.discord = {
enabled: true,
token: process.env.DISCORD_BOT_TOKEN,
dmPolicy: (process.env.DISCORD_DM_POLICY as 'pairing' | 'allowlist' | 'open') || 'pairing',
allowedUsers: parseList(process.env.DISCORD_ALLOWED_USERS),
};
}
return [{
name: agentName,
id,
model,
channels,
features: config.features,
polling: config.polling,
integrations: config.integrations,
}];
}

View File

@@ -4,17 +4,19 @@
* Single agent, single conversation - chat continues across all channels.
*/
import { createAgent, createSession, resumeSession, type Session } from '@letta-ai/letta-code-sdk';
import { createAgent, createSession, resumeSession, imageFromFile, imageFromURL, type Session, type MessageContentItem, type SendMessage } from '@letta-ai/letta-code-sdk';
import { mkdirSync } from 'node:fs';
import type { ChannelAdapter } from '../channels/types.js';
import type { BotConfig, InboundMessage, TriggerContext } from './types.js';
import type { AgentSession } from './interfaces.js';
import { Store } from './store.js';
import { updateAgentName, getPendingApprovals, rejectApproval, cancelRuns, recoverOrphanedConversationApproval } from '../tools/letta-api.js';
import { installSkillsToAgent } from '../skills/loader.js';
import { formatMessageEnvelope, type SessionContextOptions } from './formatter.js';
import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js';
import type { GroupBatcher } from './group-batcher.js';
import { loadMemoryBlocks } from './memory.js';
import { SYSTEM_PROMPT } from './system-prompt.js';
import { StreamWatchdog } from './stream-watchdog.js';
/**
* Detect if an error is a 409 CONFLICT from an orphaned approval.
@@ -32,7 +34,53 @@ function isApprovalConflictError(error: unknown): boolean {
return false;
}
export class LettaBot {
const SUPPORTED_IMAGE_MIMES = new Set([
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
]);
async function buildMultimodalMessage(
formattedText: string,
msg: InboundMessage,
): Promise<SendMessage> {
// Respect opt-out: when INLINE_IMAGES=false, skip multimodal and only send file paths in envelope
if (process.env.INLINE_IMAGES === 'false') {
return formattedText;
}
const imageAttachments = (msg.attachments ?? []).filter(
(a) => a.kind === 'image'
&& (a.localPath || a.url)
&& (!a.mimeType || SUPPORTED_IMAGE_MIMES.has(a.mimeType))
);
if (imageAttachments.length === 0) {
return formattedText;
}
const content: MessageContentItem[] = [
{ type: 'text', text: formattedText },
];
for (const attachment of imageAttachments) {
try {
if (attachment.localPath) {
content.push(imageFromFile(attachment.localPath));
} else if (attachment.url) {
content.push(await imageFromURL(attachment.url));
}
} catch (err) {
console.warn(`[Bot] Failed to load image ${attachment.name || 'unknown'}: ${err instanceof Error ? err.message : err}`);
}
}
if (content.length > 1) {
console.log(`[Bot] Sending ${content.length - 1} inline image(s) to LLM`);
}
return content.length > 1 ? content : formattedText;
}
export class LettaBot implements AgentSession {
private store: Store;
private config: BotConfig;
private channels: Map<string, ChannelAdapter> = new Map();
@@ -41,6 +89,9 @@ export class LettaBot {
// Callback to trigger heartbeat (set by main.ts)
public onTriggerHeartbeat?: () => Promise<void>;
private groupBatcher?: GroupBatcher;
private groupIntervals: Map<string, number> = new Map(); // channel -> intervalMin
private instantGroupIds: Set<string> = new Set(); // channel:id keys for instant processing
private processing = false;
constructor(config: BotConfig) {
@@ -50,7 +101,7 @@ export class LettaBot {
mkdirSync(config.workingDir, { recursive: true });
// Store in project root (same as main.ts reads for LETTA_AGENT_ID)
this.store = new Store('lettabot-agent.json');
this.store = new Store('lettabot-agent.json', config.agentName);
console.log(`LettaBot initialized. Agent ID: ${this.store.agentId || '(new)'}`);
}
@@ -65,6 +116,37 @@ export class LettaBot {
console.log(`Registered channel: ${adapter.name}`);
}
/**
* Set the group batcher and per-channel intervals.
*/
setGroupBatcher(batcher: GroupBatcher, intervals: Map<string, number>, instantGroupIds?: Set<string>): void {
this.groupBatcher = batcher;
this.groupIntervals = intervals;
if (instantGroupIds) {
this.instantGroupIds = instantGroupIds;
}
console.log('[Bot] Group batcher configured');
}
/**
* Inject a batched group message into the queue and trigger processing.
* Called by GroupBatcher's onFlush callback.
*/
processGroupBatch(msg: InboundMessage, adapter: ChannelAdapter): void {
const count = msg.batchedMessages?.length || 0;
console.log(`[Bot] Group batch: ${count} messages from ${msg.channel}:${msg.chatId}`);
// Unwrap single-message batches so they use formatMessageEnvelope (DM-style)
// instead of the chat-log batch format
const effective = (count === 1 && msg.batchedMessages)
? msg.batchedMessages[0]
: msg;
this.messageQueue.push({ msg: effective, adapter });
if (!this.processing) {
this.processQueue().catch(err => console.error('[Queue] Fatal error in processQueue:', err));
}
}
/**
* Handle slash commands
*/
@@ -218,7 +300,18 @@ export class LettaBot {
*/
private async handleMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise<void> {
console.log(`[${msg.channel}] Message from ${msg.userId}: ${msg.text}`);
// Route group messages to batcher if configured
if (msg.isGroup && this.groupBatcher) {
// Check if this group is configured for instant processing
const isInstant = this.instantGroupIds.has(`${msg.channel}:${msg.chatId}`)
|| (msg.serverId && this.instantGroupIds.has(`${msg.channel}:${msg.serverId}`));
const intervalMin = isInstant ? 0 : (this.groupIntervals.get(msg.channel) ?? 10);
console.log(`[Bot] Group message routed to batcher (interval=${intervalMin}min, mentioned=${msg.wasMentioned}, instant=${!!isInstant})`);
this.groupBatcher.enqueue(msg, adapter, intervalMin);
return;
}
// Add to queue
this.messageQueue.push({ msg, adapter });
console.log(`[Queue] Added to queue, length: ${this.messageQueue.length}, processing: ${this.processing}`);
@@ -393,9 +486,12 @@ export class LettaBot {
} : undefined;
// Send message to agent with metadata envelope
const formattedMessage = formatMessageEnvelope(msg, {}, sessionContext);
const formattedText = msg.isBatch && msg.batchedMessages
? formatGroupBatchEnvelope(msg.batchedMessages)
: formatMessageEnvelope(msg, {}, sessionContext);
const messageToSend = await buildMultimodalMessage(formattedText, msg);
try {
await withTimeout(session.send(formattedMessage), 'Session send');
await withTimeout(session.send(messageToSend), 'Session send');
} catch (sendError) {
// Check for 409 CONFLICT from orphaned approval_request_message
if (!retried && isApprovalConflictError(sendError) && this.store.agentId && this.store.conversationId) {
@@ -425,21 +521,6 @@ export class LettaBot {
let receivedAnyData = false; // Track if we got ANY stream data
const msgTypeCounts: Record<string, number> = {};
// Stream watchdog - abort if idle for too long
const watchdog = new StreamWatchdog({
onAbort: () => {
session.abort().catch((err) => {
console.error('[Bot] Stream abort failed:', err);
});
try {
session.close();
} catch (err) {
console.error('[Bot] Stream close failed:', err);
}
},
});
watchdog.start();
// Helper to finalize and send current accumulated response
const finalizeMessage = async () => {
// Check for silent marker - agent chose not to reply
@@ -462,7 +543,8 @@ export class LettaBot {
const preview = response.length > 50 ? response.slice(0, 50) + '...' : response;
console.log(`[Bot] Sent: "${preview}"`);
} catch {
// Ignore send errors
// Edit failures (e.g. "message not modified") are OK if we already sent the message
if (messageId) sentAnyMessage = true;
}
}
// Reset for next message bubble
@@ -476,10 +558,19 @@ export class LettaBot {
adapter.sendTypingIndicator(msg.chatId).catch(() => {});
}, 4000);
const seenToolCallIds = new Set<string>();
try {
for await (const streamMsg of session.stream()) {
// Deduplicate tool_call chunks: the server streams tool_call_message
// events token-by-token as arguments are generated, so a single tool
// call produces many wire events with the same toolCallId.
// Only count/log the first chunk per unique toolCallId.
if (streamMsg.type === 'tool_call') {
const toolCallId = (streamMsg as any).toolCallId;
if (toolCallId && seenToolCallIds.has(toolCallId)) continue;
if (toolCallId) seenToolCallIds.add(toolCallId);
}
const msgUuid = (streamMsg as any).uuid;
watchdog.ping();
receivedAnyData = true;
msgTypeCounts[streamMsg.type] = (msgTypeCounts[streamMsg.type] || 0) + 1;
@@ -539,9 +630,12 @@ export class LettaBot {
} else {
const result = await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
messageId = result.messageId;
sentAnyMessage = true;
}
} catch {
// Ignore edit errors
} catch (editErr) {
// Log but don't fail - streaming edits are best-effort
// (e.g. rate limits, MarkdownV2 formatting issues mid-stream)
console.warn('[Bot] Streaming edit failed:', editErr instanceof Error ? editErr.message : editErr);
}
lastUpdate = Date.now();
}
@@ -567,7 +661,6 @@ export class LettaBot {
console.log('[Bot] Empty result - attempting orphaned approval recovery...');
session.close();
clearInterval(typingInterval);
watchdog.stop();
const convResult = await recoverOrphanedConversationApproval(
this.store.agentId,
this.store.conversationId
@@ -606,7 +699,6 @@ export class LettaBot {
}
} finally {
watchdog.stop();
clearInterval(typingInterval);
}
@@ -617,6 +709,12 @@ export class LettaBot {
response = '';
}
// Detect unsupported multimodal: images were sent but server replaced them
const sentImages = Array.isArray(messageToSend);
if (sentImages && response.includes('[Image omitted]')) {
console.warn('[Bot] Model does not support images — server replaced inline images with "[Image omitted]". Consider using a vision-capable model or setting features.inlineImages: false in config.');
}
// Send final response
if (response.trim()) {
try {
@@ -632,11 +730,15 @@ export class LettaBot {
console.log(`[Bot] Sent: "${preview}"`);
} catch (sendError) {
console.error('[Bot] Error sending response:', sendError);
if (!messageId) {
// If edit failed (messageId exists), send the complete response as a new message
// so the user isn't left with a truncated streaming edit
try {
await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
sentAnyMessage = true;
// Reset recovery counter on successful response
this.store.resetRecoveryAttempts();
} catch (retryError) {
console.error('[Bot] Retry send also failed:', retryError);
}
}
}
@@ -677,11 +779,15 @@ export class LettaBot {
} catch (error) {
console.error('[Bot] Error processing message:', error);
await adapter.sendMessage({
chatId: msg.chatId,
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
threadId: msg.threadId,
});
try {
await adapter.sendMessage({
chatId: msg.chatId,
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
threadId: msg.threadId,
});
} catch (sendError) {
console.error('[Bot] Failed to send error message to channel:', sendError);
}
} finally {
session!?.close();
}
@@ -799,40 +905,20 @@ export class LettaBot {
}
let response = '';
const watchdog = new StreamWatchdog({
onAbort: () => {
console.warn('[Bot] sendToAgent stream idle timeout, aborting session...');
session.abort().catch((err) => {
console.error('[Bot] sendToAgent abort failed:', err);
});
try {
session.close();
} catch (err) {
console.error('[Bot] sendToAgent close failed:', err);
}
},
});
watchdog.start();
try {
for await (const msg of session.stream()) {
watchdog.ping();
if (msg.type === 'assistant') {
response += msg.content;
}
if (msg.type === 'result') {
if (session.agentId && session.agentId !== this.store.agentId) {
const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined);
} else if (session.conversationId && session.conversationId !== this.store.conversationId) {
this.store.conversationId = session.conversationId;
}
break;
}
for await (const msg of session.stream()) {
if (msg.type === 'assistant') {
response += msg.content;
}
if (msg.type === 'result') {
if (session.agentId && session.agentId !== this.store.agentId) {
const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
this.store.setAgent(session.agentId, currentBaseUrl, session.conversationId || undefined);
} else if (session.conversationId && session.conversationId !== this.store.conversationId) {
this.store.conversationId = session.conversationId;
}
break;
}
} finally {
watchdog.stop();
}
return response;

View File

@@ -39,6 +39,31 @@ const DEFAULT_OPTIONS: EnvelopeOptions = {
includeGroup: true,
};
/**
* Format a short time string (e.g., "4:30 PM")
*/
function formatShortTime(date: Date, options: EnvelopeOptions): string {
let timeZone: string | undefined;
if (options.timezone === 'utc') {
timeZone = 'UTC';
} else if (options.timezone && options.timezone !== 'local') {
try {
new Intl.DateTimeFormat('en-US', { timeZone: options.timezone });
timeZone = options.timezone;
} catch {
timeZone = undefined;
}
}
const formatter = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZone,
});
return formatter.format(date);
}
/**
* Session context options for first-message enrichment
*/
@@ -337,3 +362,60 @@ export function formatMessageEnvelope(
}
return reminder;
}
/**
* Format a group batch of messages as a chat log for the agent.
*
* Output format:
* [GROUP CHAT - discord:123 #general - 3 messages]
* [4:30 PM] Alice: Hey everyone
* [4:32 PM] Bob: What's up?
* [4:35 PM] Alice: @LettaBot can you help?
* (Format: **bold** *italic* ...)
*/
export function formatGroupBatchEnvelope(
messages: InboundMessage[],
options: EnvelopeOptions = {}
): string {
if (messages.length === 0) return '';
const opts = { ...DEFAULT_OPTIONS, ...options };
const first = messages[0];
// Header: [GROUP CHAT - channel:chatId #groupName - N messages]
const headerParts: string[] = ['GROUP CHAT'];
headerParts.push(`${first.channel}:${first.chatId}`);
if (first.groupName?.trim()) {
if ((first.channel === 'slack' || first.channel === 'discord') && !first.groupName.startsWith('#')) {
headerParts.push(`#${first.groupName}`);
} else {
headerParts.push(first.groupName);
}
}
headerParts.push(`${messages.length} message${messages.length === 1 ? '' : 's'}`);
const header = `[${headerParts.join(' - ')}]`;
// Chat log lines
const lines = messages.map((msg) => {
const time = formatShortTime(msg.timestamp, opts);
const sender = formatSender(msg);
const textParts: string[] = [];
if (msg.text?.trim()) textParts.push(msg.text.trim());
if (msg.reaction) {
const action = msg.reaction.action || 'added';
textParts.push(`[Reaction ${action}: ${msg.reaction.emoji}]`);
}
if (msg.attachments && msg.attachments.length > 0) {
const names = msg.attachments.map((a) => a.name || 'attachment').join(', ');
textParts.push(`[Attachments: ${names}]`);
}
const body = textParts.join(' ') || '(empty)';
return `[${time}] ${sender}: ${body}`;
});
// Format hint
const formatHint = CHANNEL_FORMATS[first.channel];
const hint = formatHint ? `\n(Format: ${formatHint})` : '';
return `${header}\n${lines.join('\n')}${hint}`;
}

92
src/core/gateway.test.ts Normal file
View File

@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LettaGateway } from './gateway.js';
import type { AgentSession } from './interfaces.js';
function createMockSession(channels: string[] = ['telegram']): AgentSession {
return {
registerChannel: vi.fn(),
setGroupBatcher: vi.fn(),
processGroupBatch: vi.fn(),
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
sendToAgent: vi.fn().mockResolvedValue('response'),
deliverToChannel: vi.fn().mockResolvedValue('msg-123'),
getStatus: vi.fn().mockReturnValue({ agentId: 'agent-123', channels }),
setAgentId: vi.fn(),
reset: vi.fn(),
getLastMessageTarget: vi.fn().mockReturnValue(null),
getLastUserMessageTime: vi.fn().mockReturnValue(null),
};
}
describe('LettaGateway', () => {
let gateway: LettaGateway;
beforeEach(() => {
gateway = new LettaGateway();
});
it('adds and retrieves agents', () => {
const session = createMockSession();
gateway.addAgent('test', session);
expect(gateway.getAgent('test')).toBe(session);
expect(gateway.getAgentNames()).toEqual(['test']);
expect(gateway.size).toBe(1);
});
it('rejects empty agent names', () => {
expect(() => gateway.addAgent('', createMockSession())).toThrow('empty');
});
it('rejects duplicate agent names', () => {
gateway.addAgent('test', createMockSession());
expect(() => gateway.addAgent('test', createMockSession())).toThrow('already exists');
});
it('starts all agents', async () => {
const s1 = createMockSession();
const s2 = createMockSession();
gateway.addAgent('a', s1);
gateway.addAgent('b', s2);
await gateway.start();
expect(s1.start).toHaveBeenCalled();
expect(s2.start).toHaveBeenCalled();
});
it('stops all agents', async () => {
const s1 = createMockSession();
const s2 = createMockSession();
gateway.addAgent('a', s1);
gateway.addAgent('b', s2);
await gateway.stop();
expect(s1.stop).toHaveBeenCalled();
expect(s2.stop).toHaveBeenCalled();
});
it('routes deliverToChannel to correct agent', async () => {
const s1 = createMockSession(['telegram']);
const s2 = createMockSession(['discord']);
gateway.addAgent('a', s1);
gateway.addAgent('b', s2);
await gateway.deliverToChannel('discord', 'chat-1', { text: 'hello' });
expect(s2.deliverToChannel).toHaveBeenCalledWith('discord', 'chat-1', { text: 'hello' });
expect(s1.deliverToChannel).not.toHaveBeenCalled();
});
it('throws when no agent owns channel', async () => {
gateway.addAgent('a', createMockSession(['telegram']));
await expect(gateway.deliverToChannel('slack', 'ch-1', { text: 'hi' })).rejects.toThrow('No agent owns channel');
});
it('handles start failures gracefully', async () => {
const good = createMockSession();
const bad = createMockSession();
(bad.start as any).mockRejectedValue(new Error('boom'));
gateway.addAgent('good', good);
gateway.addAgent('bad', bad);
// Should not throw -- uses Promise.allSettled
await gateway.start();
expect(good.start).toHaveBeenCalled();
});
});

92
src/core/gateway.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* LettaGateway - Orchestrates multiple agent sessions.
*
* In multi-agent mode, the gateway manages multiple AgentSession instances,
* each with their own channels, message queue, and state.
*
* See: docs/multi-agent-architecture.md
*/
import type { AgentSession, MessageDeliverer } from './interfaces.js';
export class LettaGateway implements MessageDeliverer {
private agents: Map<string, AgentSession> = new Map();
/**
* Add a named agent session to the gateway.
* @throws if name is empty or already exists
*/
addAgent(name: string, session: AgentSession): void {
if (!name?.trim()) {
throw new Error('Agent name cannot be empty');
}
if (this.agents.has(name)) {
throw new Error(`Agent "${name}" already exists`);
}
this.agents.set(name, session);
console.log(`[Gateway] Added agent: ${name}`);
}
/** Get an agent session by name */
getAgent(name: string): AgentSession | undefined {
return this.agents.get(name);
}
/** Get all agent names */
getAgentNames(): string[] {
return Array.from(this.agents.keys());
}
/** Get agent count */
get size(): number {
return this.agents.size;
}
/** Start all agents */
async start(): Promise<void> {
console.log(`[Gateway] Starting ${this.agents.size} agent(s)...`);
const results = await Promise.allSettled(
Array.from(this.agents.entries()).map(async ([name, session]) => {
await session.start();
console.log(`[Gateway] Started: ${name}`);
})
);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length > 0) {
console.error(`[Gateway] ${failed.length} agent(s) failed to start`);
}
console.log(`[Gateway] ${results.length - failed.length}/${results.length} agents started`);
}
/** Stop all agents */
async stop(): Promise<void> {
console.log(`[Gateway] Stopping all agents...`);
for (const [name, session] of this.agents) {
try {
await session.stop();
console.log(`[Gateway] Stopped: ${name}`);
} catch (e) {
console.error(`[Gateway] Failed to stop ${name}:`, e);
}
}
}
/**
* Deliver a message to a channel.
* Finds the agent that owns the channel and delegates.
*/
async deliverToChannel(
channelId: string,
chatId: string,
options: { text?: string; filePath?: string; kind?: 'image' | 'file' }
): Promise<string | undefined> {
// Try each agent until one owns the channel
for (const [name, session] of this.agents) {
const status = session.getStatus();
if (status.channels.includes(channelId)) {
return session.deliverToChannel(channelId, chatId, options);
}
}
throw new Error(`No agent owns channel: ${channelId}`);
}
}

113
src/core/group-batcher.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* Group Message Batcher
*
* Buffers group chat messages and flushes them periodically or on @mention.
* Channel-agnostic: works with any ChannelAdapter.
*/
import type { ChannelAdapter } from '../channels/types.js';
import type { InboundMessage } from './types.js';
export interface BufferEntry {
messages: InboundMessage[];
adapter: ChannelAdapter;
timer: ReturnType<typeof setTimeout> | null;
}
export type OnFlushCallback = (msg: InboundMessage, adapter: ChannelAdapter) => void;
export class GroupBatcher {
private buffer: Map<string, BufferEntry> = new Map();
private onFlush: OnFlushCallback;
constructor(onFlush: OnFlushCallback) {
this.onFlush = onFlush;
}
/**
* Add a group message to the buffer.
* If wasMentioned, flush immediately.
* If intervalMin is 0, flush on every message (no batching).
* Otherwise, start a timer on the first message (does NOT reset on subsequent messages).
*/
enqueue(msg: InboundMessage, adapter: ChannelAdapter, intervalMin: number): void {
const key = `${msg.channel}:${msg.chatId}`;
let entry = this.buffer.get(key);
if (!entry) {
entry = { messages: [], adapter, timer: null };
this.buffer.set(key, entry);
}
entry.messages.push(msg);
entry.adapter = adapter; // Update adapter reference
// Immediate flush: @mention or intervalMin=0
if (msg.wasMentioned || intervalMin === 0) {
this.flush(key);
return;
}
// Start timer on first message only (don't reset to prevent starvation)
if (!entry.timer) {
const ms = intervalMin * 60 * 1000;
entry.timer = setTimeout(() => {
this.flush(key);
}, ms);
}
}
/**
* Flush buffered messages for a key, building a synthetic batch InboundMessage.
*/
flush(key: string): void {
const entry = this.buffer.get(key);
if (!entry || entry.messages.length === 0) return;
// Clear timer
if (entry.timer) {
clearTimeout(entry.timer);
entry.timer = null;
}
const messages = entry.messages;
const adapter = entry.adapter;
// Remove from buffer
this.buffer.delete(key);
// Use the last message as the base for the synthetic batch message
const last = messages[messages.length - 1];
const batchMsg: InboundMessage = {
channel: last.channel,
chatId: last.chatId,
userId: last.userId,
userName: last.userName,
userHandle: last.userHandle,
messageId: last.messageId,
text: messages.map((m) => m.text).join('\n'),
timestamp: last.timestamp,
isGroup: true,
groupName: last.groupName,
wasMentioned: messages.some((m) => m.wasMentioned),
isBatch: true,
batchedMessages: messages,
};
this.onFlush(batchMsg, adapter);
}
/**
* Clear all timers on shutdown.
*/
stop(): void {
for (const [, entry] of this.buffer) {
if (entry.timer) {
clearTimeout(entry.timer);
entry.timer = null;
}
}
this.buffer.clear();
}
}

View File

@@ -5,5 +5,7 @@
export * from './types.js';
export * from './store.js';
export * from './bot.js';
export * from './interfaces.js';
export * from './gateway.js';
export * from './formatter.js';
export * from './prompts.js';

68
src/core/interfaces.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* AgentSession interface - the contract for agent communication.
*
* Consumers (cron, heartbeat, polling, API server) depend on this interface,
* not the concrete LettaBot class. This enables multi-agent orchestration
* via LettaGateway without changing consumer code.
*/
import type { ChannelAdapter } from '../channels/types.js';
import type { InboundMessage, TriggerContext } from './types.js';
import type { GroupBatcher } from './group-batcher.js';
export interface AgentSession {
/** Register a channel adapter */
registerChannel(adapter: ChannelAdapter): void;
/** Configure group message batching */
setGroupBatcher(batcher: GroupBatcher, intervals: Map<string, number>, instantGroupIds?: Set<string>): void;
/** Process a batched group message */
processGroupBatch(msg: InboundMessage, adapter: ChannelAdapter): void;
/** Start all registered channels */
start(): Promise<void>;
/** Stop all channels */
stop(): Promise<void>;
/** Send a message to the agent (used by cron, heartbeat, polling) */
sendToAgent(text: string, context?: TriggerContext): Promise<string>;
/** Deliver a message/file to a specific channel */
deliverToChannel(channelId: string, chatId: string, options: {
text?: string;
filePath?: string;
kind?: 'image' | 'file';
}): Promise<string | undefined>;
/** Get agent status */
getStatus(): { agentId: string | null; channels: string[] };
/** Set agent ID (for container deploys) */
setAgentId(agentId: string): void;
/** Reset agent state */
reset(): void;
/** Get the last message target (for heartbeat delivery) */
getLastMessageTarget(): { channel: string; chatId: string } | null;
/** Get the time of the last user message (for heartbeat skip logic) */
getLastUserMessageTime(): Date | null;
/** Callback to trigger heartbeat */
onTriggerHeartbeat?: () => Promise<void>;
}
/**
* Minimal interface for message delivery.
* Satisfied by both AgentSession and LettaGateway.
*/
export interface MessageDeliverer {
deliverToChannel(channelId: string, chatId: string, options: {
text?: string;
filePath?: string;
kind?: 'image' | 'file';
}): Promise<string | undefined>;
}

View File

@@ -53,6 +53,32 @@ If you have nothing to do → just end your turn (no output needed)
`.trim();
}
/**
* Custom heartbeat prompt - wraps user-provided text with silent mode envelope
*/
export function buildCustomHeartbeatPrompt(
customPrompt: string,
time: string,
timezone: string,
intervalMinutes: number
): string {
return `
${SILENT_MODE_PREFIX}
TRIGGER: Scheduled heartbeat
TIME: ${time} (${timezone})
NEXT HEARTBEAT: in ${intervalMinutes} minutes
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
YOUR TEXT OUTPUT IS PRIVATE - only you can see it.
To actually contact your human, run:
lettabot-message send --text "Your message here"
${customPrompt}
`.trim();
}
/**
* Cron job prompt (silent mode) - for background scheduled tasks
*/

228
src/core/store.test.ts Normal file
View File

@@ -0,0 +1,228 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Store } from './store.js';
import { existsSync, unlinkSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { AgentStore } from './types.js';
describe('Store', () => {
const testDir = join(tmpdir(), 'lettabot-test-' + Date.now() + '-' + Math.random().toString(36).substring(7));
const testStorePath = join(testDir, 'test-store.json');
let originalLettaAgentId: string | undefined;
beforeEach(() => {
// Create test directory
mkdirSync(testDir, { recursive: true });
// Clear LETTA_AGENT_ID env var to avoid interference
originalLettaAgentId = process.env.LETTA_AGENT_ID;
delete process.env.LETTA_AGENT_ID;
});
afterEach(() => {
// Clean up test files
if (existsSync(testStorePath)) {
unlinkSync(testStorePath);
}
// Restore LETTA_AGENT_ID env var
if (originalLettaAgentId !== undefined) {
process.env.LETTA_AGENT_ID = originalLettaAgentId;
}
});
it('should auto-migrate v1 format to v2', () => {
// Write v1 format store
const v1Data: AgentStore = {
agentId: 'agent-123',
conversationId: 'conv-456',
baseUrl: 'http://localhost:8283',
createdAt: '2026-01-01T00:00:00.000Z',
lastUsedAt: '2026-01-02T00:00:00.000Z',
};
writeFileSync(testStorePath, JSON.stringify(v1Data, null, 2));
// Load store (should trigger migration)
const store = new Store(testStorePath);
// Verify data is accessible
expect(store.agentId).toBe('agent-123');
expect(store.conversationId).toBe('conv-456');
expect(store.baseUrl).toBe('http://localhost:8283');
// Verify file was migrated to v2
const fs = require('node:fs');
const migrated = JSON.parse(fs.readFileSync(testStorePath, 'utf-8'));
expect(migrated.version).toBe(2);
expect(migrated.agents.LettaBot).toBeDefined();
expect(migrated.agents.LettaBot.agentId).toBe('agent-123');
});
it('should load v2 format correctly', () => {
// Write v2 format store
const v2Data = {
version: 2,
agents: {
TestBot: {
agentId: 'agent-789',
conversationId: 'conv-abc',
baseUrl: 'http://localhost:8283',
},
},
};
writeFileSync(testStorePath, JSON.stringify(v2Data, null, 2));
// Load store with agent name
const store = new Store(testStorePath, 'TestBot');
// Verify data is accessible
expect(store.agentId).toBe('agent-789');
expect(store.conversationId).toBe('conv-abc');
});
it('should isolate per-agent state', () => {
// Create two stores with different agent names
const store1 = new Store(testStorePath, 'Bot1');
const store2 = new Store(testStorePath, 'Bot2');
// Set different data for each
store1.agentId = 'agent-1';
store1.conversationId = 'conv-1';
store2.agentId = 'agent-2';
store2.conversationId = 'conv-2';
// Verify isolation
expect(store1.agentId).toBe('agent-1');
expect(store2.agentId).toBe('agent-2');
expect(store1.conversationId).toBe('conv-1');
expect(store2.conversationId).toBe('conv-2');
// Reload and verify persistence
const store1Reloaded = new Store(testStorePath, 'Bot1');
const store2Reloaded = new Store(testStorePath, 'Bot2');
expect(store1Reloaded.agentId).toBe('agent-1');
expect(store2Reloaded.agentId).toBe('agent-2');
});
it('should maintain backward compatibility with no agent name', () => {
// Create store without agent name (legacy mode)
const store = new Store(testStorePath);
// Set data
store.agentId = 'legacy-agent';
store.conversationId = 'legacy-conv';
// Verify it works
expect(store.agentId).toBe('legacy-agent');
expect(store.conversationId).toBe('legacy-conv');
// Verify it uses default agent name 'LettaBot'
const fs = require('node:fs');
const data = JSON.parse(fs.readFileSync(testStorePath, 'utf-8'));
expect(data.agents.LettaBot).toBeDefined();
expect(data.agents.LettaBot.agentId).toBe('legacy-agent');
});
it('should handle empty store initialization', () => {
const store = new Store(testStorePath, 'NewBot');
expect(store.agentId).toBeNull();
expect(store.conversationId).toBeNull();
expect(store.recoveryAttempts).toBe(0);
});
it('should track recovery attempts per agent', () => {
const store1 = new Store(testStorePath, 'Bot1');
const store2 = new Store(testStorePath, 'Bot2');
// Increment for Bot1
store1.incrementRecoveryAttempts();
store1.incrementRecoveryAttempts();
// Increment for Bot2
store2.incrementRecoveryAttempts();
// Verify isolation
expect(store1.recoveryAttempts).toBe(2);
expect(store2.recoveryAttempts).toBe(1);
// Reset Bot1
store1.resetRecoveryAttempts();
expect(store1.recoveryAttempts).toBe(0);
expect(store2.recoveryAttempts).toBe(1);
});
it('should handle lastMessageTarget per agent', () => {
const store1 = new Store(testStorePath, 'Bot1');
const store2 = new Store(testStorePath, 'Bot2');
const target1 = {
channel: 'telegram' as const,
chatId: 'chat1',
updatedAt: new Date().toISOString(),
};
const target2 = {
channel: 'slack' as const,
chatId: 'chat2',
updatedAt: new Date().toISOString(),
};
store1.lastMessageTarget = target1;
store2.lastMessageTarget = target2;
expect(store1.lastMessageTarget?.chatId).toBe('chat1');
expect(store2.lastMessageTarget?.chatId).toBe('chat2');
});
it('should handle reset() per agent', () => {
const store1 = new Store(testStorePath, 'Bot1');
const store2 = new Store(testStorePath, 'Bot2');
// Set data for both
store1.agentId = 'agent-1';
store2.agentId = 'agent-2';
// Reset only Bot1
store1.reset();
expect(store1.agentId).toBeNull();
expect(store2.agentId).toBe('agent-2');
});
it('should handle setAgent() per agent', () => {
const store = new Store(testStorePath, 'TestBot');
store.setAgent('agent-xyz', 'http://localhost:8283', 'conv-123');
expect(store.agentId).toBe('agent-xyz');
expect(store.baseUrl).toBe('http://localhost:8283');
expect(store.conversationId).toBe('conv-123');
const info = store.getInfo();
expect(info.agentId).toBe('agent-xyz');
expect(info.createdAt).toBeDefined();
expect(info.lastUsedAt).toBeDefined();
});
it('should handle isServerMismatch() per agent', () => {
const store = new Store(testStorePath, 'TestBot');
store.setAgent('agent-123', 'http://localhost:8283');
expect(store.isServerMismatch('http://localhost:8283')).toBe(false);
expect(store.isServerMismatch('http://localhost:8284')).toBe(true);
expect(store.isServerMismatch('https://api.letta.com')).toBe(true);
});
it('should not apply LETTA_AGENT_ID override to non-default agent keys', () => {
process.env.LETTA_AGENT_ID = 'global-agent';
const defaultStore = new Store(testStorePath, 'LettaBot');
const namedStore = new Store(testStorePath, 'Bot2');
expect(defaultStore.agentId).toBe('global-agent');
expect(namedStore.agentId).toBeNull();
});
});

View File

@@ -1,7 +1,8 @@
/**
* Agent Store - Persists the single agent ID
* Agent Store - Persists agent state with multi-agent support
*
* Since we use dmScope: "main", there's only ONE agent shared across all channels.
* V2 format: { version: 2, agents: { [name]: AgentStore } }
* V1 format (legacy): { agentId: ..., ... } - auto-migrated to V2
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
@@ -11,66 +12,127 @@ import { getDataDir } from '../utils/paths.js';
const DEFAULT_STORE_PATH = 'lettabot-agent.json';
interface StoreV2 {
version: 2;
agents: Record<string, AgentStore>;
}
export class Store {
private storePath: string;
private data: AgentStore;
private data: StoreV2;
private agentName: string;
constructor(storePath?: string) {
constructor(storePath?: string, agentName?: string) {
this.storePath = resolve(getDataDir(), storePath || DEFAULT_STORE_PATH);
this.agentName = agentName || 'LettaBot';
this.data = this.load();
}
private load(): AgentStore {
private load(): StoreV2 {
try {
if (existsSync(this.storePath)) {
const raw = readFileSync(this.storePath, 'utf-8');
return JSON.parse(raw) as AgentStore;
const rawData = JSON.parse(raw) as any;
// V1 -> V2 auto-migration
if (!rawData.version && rawData.agentId !== undefined) {
const migrated: StoreV2 = {
version: 2,
agents: { [this.agentName]: rawData }
};
// Write back migrated data
this.writeRaw(migrated);
return migrated;
}
// Already V2
if (rawData.version === 2) {
return rawData as StoreV2;
}
}
} catch (e) {
console.error('Failed to load agent store:', e);
}
return { agentId: null };
// Return empty V2 structure
return { version: 2, agents: {} };
}
private save(): void {
private writeRaw(data: StoreV2): void {
try {
// Ensure directory exists (important for Railway volumes)
mkdirSync(dirname(this.storePath), { recursive: true });
writeFileSync(this.storePath, JSON.stringify(this.data, null, 2));
writeFileSync(this.storePath, JSON.stringify(data, null, 2));
} catch (e) {
console.error('Failed to save agent store:', e);
}
}
private save(): void {
// Reload file to get latest data from other Store instances
const current = existsSync(this.storePath)
? (() => {
try {
const raw = readFileSync(this.storePath, 'utf-8');
const data = JSON.parse(raw);
return data.version === 2 ? data : { version: 2, agents: {} };
} catch {
return { version: 2, agents: {} };
}
})()
: { version: 2, agents: {} };
// Merge our agent's data
current.agents[this.agentName] = this.data.agents[this.agentName];
// Write merged data
this.writeRaw(current);
}
/**
* Get agent-specific data (creates entry if doesn't exist)
*/
private agentData(): AgentStore {
if (!this.data.agents[this.agentName]) {
this.data.agents[this.agentName] = { agentId: null };
}
return this.data.agents[this.agentName];
}
get agentId(): string | null {
// Allow env var override (useful for local server testing with specific agent)
return this.data.agentId || process.env.LETTA_AGENT_ID || null;
// Keep legacy env var override only for default single-agent key.
// In multi-agent mode, a global LETTA_AGENT_ID would leak across agents.
if (this.agentName === 'LettaBot') {
return this.agentData().agentId || process.env.LETTA_AGENT_ID || null;
}
return this.agentData().agentId || null;
}
set agentId(id: string | null) {
this.data.agentId = id;
this.data.lastUsedAt = new Date().toISOString();
if (id && !this.data.createdAt) {
this.data.createdAt = new Date().toISOString();
const agent = this.agentData();
agent.agentId = id;
agent.lastUsedAt = new Date().toISOString();
if (id && !agent.createdAt) {
agent.createdAt = new Date().toISOString();
}
this.save();
}
get conversationId(): string | null {
return this.data.conversationId || null;
return this.agentData().conversationId || null;
}
set conversationId(id: string | null) {
this.data.conversationId = id;
this.agentData().conversationId = id;
this.save();
}
get baseUrl(): string | undefined {
return this.data.baseUrl;
return this.agentData().baseUrl;
}
set baseUrl(url: string | undefined) {
this.data.baseUrl = url;
this.agentData().baseUrl = url;
this.save();
}
@@ -78,12 +140,13 @@ export class Store {
* Set agent ID and associated server URL together
*/
setAgent(id: string | null, baseUrl?: string, conversationId?: string): void {
this.data.agentId = id;
this.data.baseUrl = baseUrl;
this.data.conversationId = conversationId || this.data.conversationId;
this.data.lastUsedAt = new Date().toISOString();
if (id && !this.data.createdAt) {
this.data.createdAt = new Date().toISOString();
const agent = this.agentData();
agent.agentId = id;
agent.baseUrl = baseUrl;
agent.conversationId = conversationId || agent.conversationId;
agent.lastUsedAt = new Date().toISOString();
if (id && !agent.createdAt) {
agent.createdAt = new Date().toISOString();
}
this.save();
}
@@ -92,48 +155,50 @@ export class Store {
* Check if stored agent matches current server
*/
isServerMismatch(currentBaseUrl?: string): boolean {
if (!this.data.agentId || !this.data.baseUrl) return false;
const agent = this.agentData();
if (!agent.agentId || !agent.baseUrl) return false;
// Normalize URLs for comparison
const stored = this.data.baseUrl.replace(/\/$/, '');
const stored = agent.baseUrl.replace(/\/$/, '');
const current = (currentBaseUrl || 'https://api.letta.com').replace(/\/$/, '');
return stored !== current;
}
reset(): void {
this.data = { agentId: null };
this.data.agents[this.agentName] = { agentId: null };
this.save();
}
getInfo(): AgentStore {
return { ...this.data };
return { ...this.agentData() };
}
get lastMessageTarget(): LastMessageTarget | null {
return this.data.lastMessageTarget || null;
return this.agentData().lastMessageTarget || null;
}
set lastMessageTarget(target: LastMessageTarget | null) {
this.data.lastMessageTarget = target || undefined;
this.agentData().lastMessageTarget = target || undefined;
this.save();
}
// Recovery tracking
get recoveryAttempts(): number {
return this.data.recoveryAttempts || 0;
return this.agentData().recoveryAttempts || 0;
}
incrementRecoveryAttempts(): number {
this.data.recoveryAttempts = (this.data.recoveryAttempts || 0) + 1;
this.data.lastRecoveryAt = new Date().toISOString();
const agent = this.agentData();
agent.recoveryAttempts = (agent.recoveryAttempts || 0) + 1;
agent.lastRecoveryAt = new Date().toISOString();
this.save();
return this.data.recoveryAttempts;
return agent.recoveryAttempts;
}
resetRecoveryAttempts(): void {
this.data.recoveryAttempts = 0;
this.agentData().recoveryAttempts = 0;
this.save();
}
}

View File

@@ -1,204 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { StreamWatchdog } from './stream-watchdog.js';
describe('StreamWatchdog', () => {
beforeEach(() => {
vi.useFakeTimers();
// Clear env var before each test
delete process.env.LETTA_STREAM_IDLE_TIMEOUT_MS;
});
afterEach(() => {
vi.useRealTimers();
});
describe('default behavior', () => {
it('uses 120s default idle timeout', () => {
const onAbort = vi.fn();
const watchdog = new StreamWatchdog({ onAbort });
watchdog.start();
// Should not abort before 120s
vi.advanceTimersByTime(119000);
expect(onAbort).not.toHaveBeenCalled();
expect(watchdog.isAborted).toBe(false);
// Should abort at 120s
vi.advanceTimersByTime(1000);
expect(onAbort).toHaveBeenCalledTimes(1);
expect(watchdog.isAborted).toBe(true);
watchdog.stop();
});
it('ping() resets the idle timer', () => {
const onAbort = vi.fn();
const watchdog = new StreamWatchdog({ onAbort });
watchdog.start();
// Advance 100s, then ping
vi.advanceTimersByTime(100000);
watchdog.ping();
// Advance another 100s - should not abort (only 100s since ping)
vi.advanceTimersByTime(100000);
expect(onAbort).not.toHaveBeenCalled();
// Advance 20 more seconds - now 120s since last ping
vi.advanceTimersByTime(20000);
expect(onAbort).toHaveBeenCalledTimes(1);
watchdog.stop();
});
it('stop() prevents abort callback', () => {
const onAbort = vi.fn();
const watchdog = new StreamWatchdog({ onAbort });
watchdog.start();
vi.advanceTimersByTime(25000);
watchdog.stop();
// Even after full timeout, should not call abort
vi.advanceTimersByTime(10000);
expect(onAbort).not.toHaveBeenCalled();
});
});
describe('custom options', () => {
it('respects custom idleTimeoutMs', () => {
const onAbort = vi.fn();
const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 5000 });
watchdog.start();
vi.advanceTimersByTime(4000);
expect(onAbort).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(onAbort).toHaveBeenCalledTimes(1);
watchdog.stop();
});
});
describe('environment variable override', () => {
it('uses LETTA_STREAM_IDLE_TIMEOUT_MS when set', () => {
process.env.LETTA_STREAM_IDLE_TIMEOUT_MS = '10000';
const onAbort = vi.fn();
const watchdog = new StreamWatchdog({ onAbort });
watchdog.start();
vi.advanceTimersByTime(9000);
expect(onAbort).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(onAbort).toHaveBeenCalledTimes(1);
watchdog.stop();
});
it('env var takes precedence over options', () => {
process.env.LETTA_STREAM_IDLE_TIMEOUT_MS = '5000';
const onAbort = vi.fn();
// Option says 60s, but env says 5s
const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 60000 });
watchdog.start();
vi.advanceTimersByTime(5000);
expect(onAbort).toHaveBeenCalledTimes(1);
watchdog.stop();
});
it('ignores invalid env var values', () => {
process.env.LETTA_STREAM_IDLE_TIMEOUT_MS = 'invalid';
const onAbort = vi.fn();
const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 5000 });
watchdog.start();
// Should use option value (5s) since env is invalid
vi.advanceTimersByTime(5000);
expect(onAbort).toHaveBeenCalledTimes(1);
watchdog.stop();
});
it('ignores zero env var value', () => {
process.env.LETTA_STREAM_IDLE_TIMEOUT_MS = '0';
const onAbort = vi.fn();
const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 5000 });
watchdog.start();
// Should use option value (5s) since env is 0
vi.advanceTimersByTime(5000);
expect(onAbort).toHaveBeenCalledTimes(1);
watchdog.stop();
});
});
describe('logging', () => {
it('logs waiting message at logIntervalMs when idle', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const watchdog = new StreamWatchdog({ logIntervalMs: 1000 });
watchdog.start();
// First interval - 1s idle
vi.advanceTimersByTime(1000);
expect(consoleSpy).toHaveBeenCalledWith(
'[Bot] Stream waiting',
expect.objectContaining({ idleMs: expect.any(Number) })
);
consoleSpy.mockRestore();
watchdog.stop();
});
});
describe('edge cases', () => {
it('can be stopped before start', () => {
const watchdog = new StreamWatchdog({});
expect(() => watchdog.stop()).not.toThrow();
});
it('multiple pings work correctly', () => {
const onAbort = vi.fn();
const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 1000 });
watchdog.start();
// Rapid pings should keep resetting
for (let i = 0; i < 10; i++) {
vi.advanceTimersByTime(500);
watchdog.ping();
}
expect(onAbort).not.toHaveBeenCalled();
// Now let it timeout
vi.advanceTimersByTime(1000);
expect(onAbort).toHaveBeenCalledTimes(1);
watchdog.stop();
});
it('abort callback only fires once', () => {
const onAbort = vi.fn();
const watchdog = new StreamWatchdog({ onAbort, idleTimeoutMs: 1000 });
watchdog.start();
vi.advanceTimersByTime(1000);
expect(onAbort).toHaveBeenCalledTimes(1);
// Even if we wait more, should not fire again
vi.advanceTimersByTime(5000);
expect(onAbort).toHaveBeenCalledTimes(1);
watchdog.stop();
});
});
});

View File

@@ -1,106 +0,0 @@
/**
* Stream Watchdog
*
* Monitors streaming responses for idle timeouts and provides
* periodic logging when the stream is waiting.
*/
export interface StreamWatchdogOptions {
/** Idle timeout in milliseconds. Default: 30000 (30s) */
idleTimeoutMs?: number;
/** Log interval when idle. Default: 10000 (10s) */
logIntervalMs?: number;
/** Called when idle timeout triggers abort */
onAbort?: () => void;
}
export class StreamWatchdog {
private idleTimer: NodeJS.Timeout | null = null;
private logTimer: NodeJS.Timeout | null = null;
private _aborted = false;
private startTime = 0;
private lastActivity = 0;
private readonly idleTimeoutMs: number;
private readonly logIntervalMs: number;
private readonly onAbort?: () => void;
constructor(options: StreamWatchdogOptions = {}) {
// Allow env override, then option, then default
const envTimeout = Number(process.env.LETTA_STREAM_IDLE_TIMEOUT_MS);
this.idleTimeoutMs = Number.isFinite(envTimeout) && envTimeout > 0
? envTimeout
: (options.idleTimeoutMs ?? 120000);
this.logIntervalMs = options.logIntervalMs ?? 10000;
this.onAbort = options.onAbort;
}
/**
* Start watching the stream
*/
start(): void {
this.startTime = Date.now();
this.lastActivity = this.startTime;
this._aborted = false;
this.resetIdleTimer();
// Periodic logging when idle
this.logTimer = setInterval(() => {
const now = Date.now();
const idleMs = now - this.lastActivity;
if (idleMs >= this.logIntervalMs) {
console.log('[Bot] Stream waiting', {
elapsedMs: now - this.startTime,
idleMs,
});
}
}, this.logIntervalMs);
}
/**
* Call on each stream chunk to reset the idle timer
*/
ping(): void {
this.lastActivity = Date.now();
this.resetIdleTimer();
}
/**
* Stop watching and cleanup all timers
*/
stop(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
if (this.logTimer) {
clearInterval(this.logTimer);
this.logTimer = null;
}
}
/**
* Whether the watchdog triggered an abort
*/
get isAborted(): boolean {
return this._aborted;
}
private resetIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
}
this.idleTimer = setTimeout(() => {
if (this._aborted) return;
this._aborted = true;
console.warn(`[Bot] Stream idle timeout after ${this.idleTimeoutMs}ms, aborting...`);
if (this.onAbort) {
this.onAbort();
}
}, this.idleTimeoutMs);
}
}

View File

@@ -76,10 +76,13 @@ export interface InboundMessage {
threadId?: string; // Slack thread_ts
isGroup?: boolean; // Is this from a group chat?
groupName?: string; // Group/channel name if applicable
serverId?: string; // Server/guild ID (Discord only)
wasMentioned?: boolean; // Was bot explicitly mentioned? (groups only)
replyToUser?: string; // Phone number of who they're replying to (if reply)
attachments?: InboundAttachment[];
reaction?: InboundReaction;
isBatch?: boolean; // Is this a batched group message?
batchedMessages?: InboundMessage[]; // Original individual messages (for batch formatting)
}
/**

196
src/cron/heartbeat.test.ts Normal file
View File

@@ -0,0 +1,196 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { HeartbeatService, type HeartbeatConfig } from './heartbeat.js';
import { buildCustomHeartbeatPrompt, SILENT_MODE_PREFIX } from '../core/prompts.js';
import type { AgentSession } from '../core/interfaces.js';
// ── buildCustomHeartbeatPrompt ──────────────────────────────────────────
describe('buildCustomHeartbeatPrompt', () => {
it('includes silent mode prefix', () => {
const result = buildCustomHeartbeatPrompt('Do something', '12:00 PM', 'UTC', 60);
expect(result).toContain(SILENT_MODE_PREFIX);
});
it('includes time and interval metadata', () => {
const result = buildCustomHeartbeatPrompt('Do something', '3:30 PM', 'America/Los_Angeles', 45);
expect(result).toContain('TIME: 3:30 PM (America/Los_Angeles)');
expect(result).toContain('NEXT HEARTBEAT: in 45 minutes');
});
it('includes custom prompt text in body', () => {
const result = buildCustomHeartbeatPrompt('Check your todo list.', '12:00 PM', 'UTC', 60);
expect(result).toContain('Check your todo list.');
});
it('includes lettabot-message instructions', () => {
const result = buildCustomHeartbeatPrompt('Custom task', '12:00 PM', 'UTC', 60);
expect(result).toContain('lettabot-message send --text');
});
it('does NOT include default body text', () => {
const result = buildCustomHeartbeatPrompt('Custom task', '12:00 PM', 'UTC', 60);
expect(result).not.toContain('This is your time');
expect(result).not.toContain('Pursue curiosities');
});
});
// ── HeartbeatService prompt resolution ──────────────────────────────────
function createMockBot(): AgentSession {
return {
registerChannel: vi.fn(),
setGroupBatcher: vi.fn(),
processGroupBatch: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
sendToAgent: vi.fn().mockResolvedValue('ok'),
deliverToChannel: vi.fn(),
getStatus: vi.fn().mockReturnValue({ agentId: 'test', channels: [] }),
setAgentId: vi.fn(),
reset: vi.fn(),
getLastMessageTarget: vi.fn().mockReturnValue(null),
getLastUserMessageTime: vi.fn().mockReturnValue(null),
};
}
function createConfig(overrides: Partial<HeartbeatConfig> = {}): HeartbeatConfig {
return {
enabled: true,
intervalMinutes: 30,
workingDir: tmpdir(),
...overrides,
};
}
describe('HeartbeatService prompt resolution', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = resolve(tmpdir(), `heartbeat-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
});
afterEach(() => {
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('uses default prompt when no custom prompt is set', async () => {
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({ workingDir: tmpDir }));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).toContain('This is your time');
expect(sentMessage).toContain(SILENT_MODE_PREFIX);
});
it('uses inline prompt when set', async () => {
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
prompt: 'Check your todo list and work on the top item.',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).toContain('Check your todo list and work on the top item.');
expect(sentMessage).not.toContain('This is your time');
expect(sentMessage).toContain(SILENT_MODE_PREFIX);
});
it('uses promptFile when no inline prompt is set', async () => {
const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt');
writeFileSync(promptPath, 'Research quantum computing papers.');
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
promptFile: 'heartbeat-prompt.txt',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).toContain('Research quantum computing papers.');
expect(sentMessage).not.toContain('This is your time');
});
it('inline prompt takes precedence over promptFile', async () => {
const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt');
writeFileSync(promptPath, 'FROM FILE');
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
prompt: 'FROM INLINE',
promptFile: 'heartbeat-prompt.txt',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(sentMessage).toContain('FROM INLINE');
expect(sentMessage).not.toContain('FROM FILE');
});
it('re-reads promptFile on each tick (live reload)', async () => {
const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt');
writeFileSync(promptPath, 'Version 1');
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
promptFile: 'heartbeat-prompt.txt',
}));
// First tick
await service.trigger();
const firstMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(firstMessage).toContain('Version 1');
// Update file
writeFileSync(promptPath, 'Version 2');
// Second tick
await service.trigger();
const secondMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[1][0] as string;
expect(secondMessage).toContain('Version 2');
expect(secondMessage).not.toContain('Version 1');
});
it('falls back to default when promptFile does not exist', async () => {
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
promptFile: 'nonexistent.txt',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
// Should fall back to default since file doesn't exist
expect(sentMessage).toContain('This is your time');
});
it('falls back to default when promptFile is empty', async () => {
const promptPath = resolve(tmpDir, 'empty.txt');
writeFileSync(promptPath, ' \n ');
const bot = createMockBot();
const service = new HeartbeatService(bot, createConfig({
workingDir: tmpDir,
promptFile: 'empty.txt',
}));
await service.trigger();
const sentMessage = (bot.sendToAgent as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
// Empty/whitespace file should fall back to default
expect(sentMessage).toContain('This is your time');
});
});

View File

@@ -7,11 +7,11 @@
* The agent must use `lettabot-message` CLI via Bash to contact the user.
*/
import { appendFileSync, mkdirSync } from 'node:fs';
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import type { LettaBot } from '../core/bot.js';
import type { AgentSession } from '../core/interfaces.js';
import type { TriggerContext } from '../core/types.js';
import { buildHeartbeatPrompt } from '../core/prompts.js';
import { buildHeartbeatPrompt, buildCustomHeartbeatPrompt } from '../core/prompts.js';
import { getDataDir } from '../utils/paths.js';
@@ -46,6 +46,9 @@ export interface HeartbeatConfig {
// Custom heartbeat prompt (optional)
prompt?: string;
// Path to prompt file (re-read each tick for live editing)
promptFile?: string;
// Target for delivery (optional - defaults to last messaged)
target?: {
channel: string;
@@ -57,11 +60,11 @@ export interface HeartbeatConfig {
* Heartbeat Service
*/
export class HeartbeatService {
private bot: LettaBot;
private bot: AgentSession;
private config: HeartbeatConfig;
private intervalId: NodeJS.Timeout | null = null;
constructor(bot: LettaBot, config: HeartbeatConfig) {
constructor(bot: AgentSession, config: HeartbeatConfig) {
this.bot = bot;
this.config = config;
}
@@ -168,8 +171,20 @@ export class HeartbeatService {
};
try {
// Build the heartbeat message with clear SILENT MODE indication
const message = buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes);
// Resolve custom prompt: inline config > promptFile (re-read each tick) > default
let customPrompt = this.config.prompt;
if (!customPrompt && this.config.promptFile) {
try {
const promptPath = resolve(this.config.workingDir, this.config.promptFile);
customPrompt = readFileSync(promptPath, 'utf-8').trim();
} catch (err) {
console.error(`[Heartbeat] Failed to read promptFile "${this.config.promptFile}":`, err);
}
}
const message = customPrompt
? buildCustomHeartbeatPrompt(customPrompt, formattedTime, timezone, this.config.intervalMinutes)
: buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes);
console.log(`[Heartbeat] Sending prompt (SILENT MODE):\n${'─'.repeat(50)}\n${message}\n${'─'.repeat(50)}\n`);

View File

@@ -7,7 +7,7 @@
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, watch, type FSWatcher } from 'node:fs';
import { resolve, dirname } from 'node:path';
import type { LettaBot } from '../core/bot.js';
import type { AgentSession } from '../core/interfaces.js';
import type { CronJob, CronJobCreate, CronSchedule, CronConfig, HeartbeatConfig } from './types.js';
import { DEFAULT_HEARTBEAT_MESSAGES } from './types.js';
import { getDataDir } from '../utils/paths.js';
@@ -49,7 +49,7 @@ const DEFAULT_HEARTBEAT: HeartbeatConfig = {
export class CronService {
private jobs: Map<string, CronJob> = new Map();
private scheduledJobs: Map<string, import('node-schedule').Job> = new Map();
private bot: LettaBot;
private bot: AgentSession;
private storePath: string;
private config: CronConfig;
private started = false;
@@ -57,7 +57,7 @@ export class CronService {
private fileWatcher: FSWatcher | null = null;
private lastFileContent: string = '';
constructor(bot: LettaBot, config?: CronConfig) {
constructor(bot: AgentSession, config?: CronConfig) {
this.bot = bot;
this.config = config || {};
this.storePath = config?.storePath

View File

@@ -20,8 +20,12 @@ import { getDataDir, getWorkingDir, hasRailwayVolume } from './utils/paths.js';
const yamlConfig = loadConfig();
const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables';
console.log(`[Config] Loaded from ${configSource}`);
console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}`);
if (yamlConfig.agent.model) {
if (yamlConfig.agents?.length) {
console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`);
} else {
console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}`);
}
if (yamlConfig.agent?.model) {
console.warn('[Config] WARNING: agent.model in lettabot.yaml is deprecated and ignored. Use `lettabot model set <handle>` instead.');
}
applyConfigToEnv(yamlConfig);
@@ -36,24 +40,43 @@ const currentBaseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
if (existsSync(STORE_PATH)) {
try {
const store = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
const raw = JSON.parse(readFileSync(STORE_PATH, 'utf-8'));
// Check for server mismatch
if (store.agentId && store.baseUrl) {
const storedUrl = store.baseUrl.replace(/\/$/, '');
const currentUrl = currentBaseUrl.replace(/\/$/, '');
if (storedUrl !== currentUrl) {
console.warn(`\n⚠ Server mismatch detected!`);
console.warn(` Stored agent was created on: ${storedUrl}`);
console.warn(` Current server: ${currentUrl}`);
console.warn(` The agent ${store.agentId} may not exist on this server.`);
console.warn(` Run 'lettabot onboard' to select or create an agent for this server.\n`);
// V2 format: get first agent's ID
if (raw.version === 2 && raw.agents) {
const firstAgent = Object.values(raw.agents)[0] as any;
if (firstAgent?.agentId) {
process.env.LETTA_AGENT_ID = firstAgent.agentId;
}
// Check server mismatch on first agent
if (firstAgent?.agentId && firstAgent?.baseUrl) {
const storedUrl = firstAgent.baseUrl.replace(/\/$/, '');
const currentUrl = currentBaseUrl.replace(/\/$/, '');
if (storedUrl !== currentUrl) {
console.warn(`\n⚠ Server mismatch detected!`);
console.warn(` Stored agent was created on: ${storedUrl}`);
console.warn(` Current server: ${currentUrl}`);
console.warn(` The agent ${firstAgent.agentId} may not exist on this server.`);
console.warn(` Run 'lettabot onboard' to select or create an agent for this server.\n`);
}
}
} else if (raw.agentId) {
// V1 format (legacy)
process.env.LETTA_AGENT_ID = raw.agentId;
// Check server mismatch
if (raw.agentId && raw.baseUrl) {
const storedUrl = raw.baseUrl.replace(/\/$/, '');
const currentUrl = currentBaseUrl.replace(/\/$/, '');
if (storedUrl !== currentUrl) {
console.warn(`\n⚠ Server mismatch detected!`);
console.warn(` Stored agent was created on: ${storedUrl}`);
console.warn(` Current server: ${currentUrl}`);
console.warn(` The agent ${raw.agentId} may not exist on this server.`);
console.warn(` Run 'lettabot onboard' to select or create an agent for this server.\n`);
}
}
}
if (store.agentId) {
process.env.LETTA_AGENT_ID = store.agentId;
}
} catch {}
}
@@ -116,12 +139,15 @@ async function refreshTokensIfNeeded(): Promise<void> {
// Run token refresh before importing SDK (which reads LETTA_API_KEY)
await refreshTokensIfNeeded();
import { normalizeAgents } from './config/types.js';
import { LettaGateway } from './core/gateway.js';
import { LettaBot } from './core/bot.js';
import { TelegramAdapter } from './channels/telegram.js';
import { SlackAdapter } from './channels/slack.js';
import { WhatsAppAdapter } from './channels/whatsapp/index.js';
import { SignalAdapter } from './channels/signal.js';
import { DiscordAdapter } from './channels/discord.js';
import { GroupBatcher } from './core/group-batcher.js';
import { CronService } from './cron/service.js';
import { HeartbeatService } from './cron/heartbeat.js';
import { PollingService } from './polling/service.js';
@@ -231,81 +257,159 @@ async function pruneAttachmentsDir(baseDir: string, maxAgeDays: number): Promise
}
}
/**
* Create channel adapters for an agent from its config
*/
function createChannelsForAgent(
agentConfig: import('./config/types.js').AgentConfig,
attachmentsDir: string,
attachmentsMaxBytes: number,
): import('./channels/types.js').ChannelAdapter[] {
const adapters: import('./channels/types.js').ChannelAdapter[] = [];
if (agentConfig.channels.telegram?.token) {
adapters.push(new TelegramAdapter({
token: agentConfig.channels.telegram.token,
dmPolicy: agentConfig.channels.telegram.dmPolicy || 'pairing',
allowedUsers: agentConfig.channels.telegram.allowedUsers && agentConfig.channels.telegram.allowedUsers.length > 0
? agentConfig.channels.telegram.allowedUsers.map(u => typeof u === 'string' ? parseInt(u, 10) : u)
: undefined,
attachmentsDir,
attachmentsMaxBytes,
}));
}
if (agentConfig.channels.slack?.botToken && agentConfig.channels.slack?.appToken) {
adapters.push(new SlackAdapter({
botToken: agentConfig.channels.slack.botToken,
appToken: agentConfig.channels.slack.appToken,
dmPolicy: agentConfig.channels.slack.dmPolicy || 'pairing',
allowedUsers: agentConfig.channels.slack.allowedUsers && agentConfig.channels.slack.allowedUsers.length > 0
? agentConfig.channels.slack.allowedUsers
: undefined,
attachmentsDir,
attachmentsMaxBytes,
}));
}
if (agentConfig.channels.whatsapp?.enabled) {
const selfChatMode = agentConfig.channels.whatsapp.selfChat ?? true;
if (!selfChatMode) {
console.warn('[WhatsApp] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!');
console.warn('[WhatsApp] Only use this if this is a dedicated bot number, not your personal WhatsApp.');
}
adapters.push(new WhatsAppAdapter({
sessionPath: process.env.WHATSAPP_SESSION_PATH || './data/whatsapp-session',
dmPolicy: agentConfig.channels.whatsapp.dmPolicy || 'pairing',
allowedUsers: agentConfig.channels.whatsapp.allowedUsers && agentConfig.channels.whatsapp.allowedUsers.length > 0
? agentConfig.channels.whatsapp.allowedUsers
: undefined,
selfChatMode,
attachmentsDir,
attachmentsMaxBytes,
}));
}
if (agentConfig.channels.signal?.phone) {
const selfChatMode = agentConfig.channels.signal.selfChat ?? true;
if (!selfChatMode) {
console.warn('[Signal] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!');
console.warn('[Signal] Only use this if this is a dedicated bot number, not your personal Signal.');
}
adapters.push(new SignalAdapter({
phoneNumber: agentConfig.channels.signal.phone,
cliPath: process.env.SIGNAL_CLI_PATH || 'signal-cli',
httpHost: process.env.SIGNAL_HTTP_HOST || '127.0.0.1',
httpPort: parseInt(process.env.SIGNAL_HTTP_PORT || '8090', 10),
dmPolicy: agentConfig.channels.signal.dmPolicy || 'pairing',
allowedUsers: agentConfig.channels.signal.allowedUsers && agentConfig.channels.signal.allowedUsers.length > 0
? agentConfig.channels.signal.allowedUsers
: undefined,
selfChatMode,
attachmentsDir,
attachmentsMaxBytes,
}));
}
if (agentConfig.channels.discord?.token) {
adapters.push(new DiscordAdapter({
token: agentConfig.channels.discord.token,
dmPolicy: agentConfig.channels.discord.dmPolicy || 'pairing',
allowedUsers: agentConfig.channels.discord.allowedUsers && agentConfig.channels.discord.allowedUsers.length > 0
? agentConfig.channels.discord.allowedUsers
: undefined,
attachmentsDir,
attachmentsMaxBytes,
}));
}
return adapters;
}
/**
* Create and configure a group batcher for an agent
*/
function createGroupBatcher(
agentConfig: import('./config/types.js').AgentConfig,
bot: import('./core/interfaces.js').AgentSession,
): { batcher: GroupBatcher | null; intervals: Map<string, number>; instantIds: Set<string> } {
const intervals = new Map<string, number>();
const instantIds = new Set<string>();
// Collect intervals from channel configs
if (agentConfig.channels.telegram) {
intervals.set('telegram', agentConfig.channels.telegram.groupPollIntervalMin ?? 10);
for (const id of agentConfig.channels.telegram.instantGroups || []) {
instantIds.add(`telegram:${id}`);
}
}
if (agentConfig.channels.slack) {
intervals.set('slack', agentConfig.channels.slack.groupPollIntervalMin ?? 10);
for (const id of agentConfig.channels.slack.instantGroups || []) {
instantIds.add(`slack:${id}`);
}
}
if (agentConfig.channels.whatsapp) {
intervals.set('whatsapp', agentConfig.channels.whatsapp.groupPollIntervalMin ?? 10);
for (const id of agentConfig.channels.whatsapp.instantGroups || []) {
instantIds.add(`whatsapp:${id}`);
}
}
if (agentConfig.channels.signal) {
intervals.set('signal', agentConfig.channels.signal.groupPollIntervalMin ?? 10);
for (const id of agentConfig.channels.signal.instantGroups || []) {
instantIds.add(`signal:${id}`);
}
}
if (agentConfig.channels.discord) {
intervals.set('discord', agentConfig.channels.discord.groupPollIntervalMin ?? 10);
for (const id of agentConfig.channels.discord.instantGroups || []) {
instantIds.add(`discord:${id}`);
}
}
if (instantIds.size > 0) {
console.log(`[Groups] Instant groups: ${[...instantIds].join(', ')}`);
}
const batcher = intervals.size > 0 ? new GroupBatcher((msg, adapter) => {
bot.processGroupBatch(msg, adapter);
}) : null;
return { batcher, intervals, instantIds };
}
// Skills are installed to agent-scoped directory when agent is created (see core/bot.ts)
// Configuration from environment
const config = {
// Global config (shared across all agents)
const globalConfig = {
workingDir: getWorkingDir(),
allowedTools: (process.env.ALLOWED_TOOLS || 'Bash,Read,Edit,Write,Glob,Grep,Task,web_search,conversation_search').split(','),
attachmentsMaxBytes: resolveAttachmentsMaxBytes(),
attachmentsMaxAgeDays: resolveAttachmentsMaxAgeDays(),
// Channel configs
telegram: {
enabled: !!process.env.TELEGRAM_BOT_TOKEN,
token: process.env.TELEGRAM_BOT_TOKEN || '',
dmPolicy: (process.env.TELEGRAM_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open',
allowedUsers: process.env.TELEGRAM_ALLOWED_USERS?.split(',').filter(Boolean).map(Number) || [],
},
slack: {
enabled: !!process.env.SLACK_BOT_TOKEN && !!process.env.SLACK_APP_TOKEN,
botToken: process.env.SLACK_BOT_TOKEN || '',
appToken: process.env.SLACK_APP_TOKEN || '',
allowedUsers: process.env.SLACK_ALLOWED_USERS?.split(',').filter(Boolean) || [],
},
whatsapp: {
enabled: process.env.WHATSAPP_ENABLED === 'true',
sessionPath: process.env.WHATSAPP_SESSION_PATH || './data/whatsapp-session',
dmPolicy: (process.env.WHATSAPP_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open',
allowedUsers: process.env.WHATSAPP_ALLOWED_USERS?.split(',').filter(Boolean) || [],
selfChatMode: process.env.WHATSAPP_SELF_CHAT_MODE !== 'false', // Default true (safe - only self-chat)
},
signal: {
enabled: !!process.env.SIGNAL_PHONE_NUMBER,
phoneNumber: process.env.SIGNAL_PHONE_NUMBER || '',
cliPath: process.env.SIGNAL_CLI_PATH || 'signal-cli',
httpHost: process.env.SIGNAL_HTTP_HOST || '127.0.0.1',
httpPort: parseInt(process.env.SIGNAL_HTTP_PORT || '8090', 10),
dmPolicy: (process.env.SIGNAL_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open',
allowedUsers: process.env.SIGNAL_ALLOWED_USERS?.split(',').filter(Boolean) || [],
selfChatMode: process.env.SIGNAL_SELF_CHAT_MODE !== 'false', // Default true
},
discord: {
enabled: !!process.env.DISCORD_BOT_TOKEN,
token: process.env.DISCORD_BOT_TOKEN || '',
dmPolicy: (process.env.DISCORD_DM_POLICY || 'pairing') as 'pairing' | 'allowlist' | 'open',
allowedUsers: process.env.DISCORD_ALLOWED_USERS?.split(',').filter(Boolean) || [],
},
// Cron
cronEnabled: process.env.CRON_ENABLED === 'true',
// Heartbeat - simpler config
heartbeat: {
enabled: !!process.env.HEARTBEAT_INTERVAL_MIN,
intervalMinutes: parseInt(process.env.HEARTBEAT_INTERVAL_MIN || '0', 10) || 30,
prompt: process.env.HEARTBEAT_PROMPT,
target: parseHeartbeatTarget(process.env.HEARTBEAT_TARGET),
},
// Polling - system-level background checks
polling: {
enabled: !!process.env.GMAIL_ACCOUNT, // Enable if any poller is configured
intervalMs: parseInt(process.env.POLLING_INTERVAL_MS || '60000', 10), // Default 1 minute
gmail: {
enabled: !!process.env.GMAIL_ACCOUNT,
account: process.env.GMAIL_ACCOUNT || '',
},
},
cronEnabled: process.env.CRON_ENABLED === 'true', // Legacy env var fallback
};
// Validate at least one channel is configured
if (!config.telegram.enabled && !config.slack.enabled && !config.whatsapp.enabled && !config.signal.enabled && !config.discord.enabled) {
console.error('\n Error: No channels configured.');
console.error(' Set TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN+SLACK_APP_TOKEN, WHATSAPP_ENABLED=true, SIGNAL_PHONE_NUMBER, or DISCORD_BOT_TOKEN\n');
process.exit(1);
}
// Validate LETTA_API_KEY is set for cloud mode (selfhosted mode doesn't require it)
if (yamlConfig.server.mode !== 'selfhosted' && !process.env.LETTA_API_KEY) {
console.error('\n Error: LETTA_API_KEY is required for Letta Cloud.');
@@ -323,224 +427,199 @@ async function main() {
console.log(`[Storage] Railway volume detected at ${process.env.RAILWAY_VOLUME_MOUNT_PATH}`);
}
console.log(`[Storage] Data directory: ${dataDir}`);
console.log(`[Storage] Working directory: ${config.workingDir}`);
console.log(`[Storage] Working directory: ${globalConfig.workingDir}`);
// Create bot with skills config (skills installed to agent-scoped location after agent creation)
const bot = new LettaBot({
workingDir: config.workingDir,
agentName: process.env.AGENT_NAME || 'LettaBot',
allowedTools: config.allowedTools,
maxToolCalls: process.env.MAX_TOOL_CALLS ? Number(process.env.MAX_TOOL_CALLS) : undefined,
skills: {
cronEnabled: config.cronEnabled,
googleEnabled: config.polling.gmail.enabled,
},
});
// Normalize config to agents array
const agents = normalizeAgents(yamlConfig);
const isMultiAgent = agents.length > 1;
console.log(`[Config] ${agents.length} agent(s) configured: ${agents.map(a => a.name).join(', ')}`);
// Validate at least one agent has channels
const totalChannels = agents.reduce((sum, a) => sum + Object.keys(a.channels).length, 0);
if (totalChannels === 0) {
console.error('\n Error: No channels configured in any agent.');
console.error(' Configure channels in lettabot.yaml or set environment variables.\n');
process.exit(1);
}
const attachmentsDir = resolve(config.workingDir, 'attachments');
pruneAttachmentsDir(attachmentsDir, config.attachmentsMaxAgeDays).catch((err) => {
const attachmentsDir = resolve(globalConfig.workingDir, 'attachments');
pruneAttachmentsDir(attachmentsDir, globalConfig.attachmentsMaxAgeDays).catch((err) => {
console.warn('[Attachments] Prune failed:', err);
});
if (config.attachmentsMaxAgeDays > 0) {
if (globalConfig.attachmentsMaxAgeDays > 0) {
const timer = setInterval(() => {
pruneAttachmentsDir(attachmentsDir, config.attachmentsMaxAgeDays).catch((err) => {
pruneAttachmentsDir(attachmentsDir, globalConfig.attachmentsMaxAgeDays).catch((err) => {
console.warn('[Attachments] Prune failed:', err);
});
}, ATTACHMENTS_PRUNE_INTERVAL_MS);
timer.unref?.();
}
// Verify agent exists (clear stale ID if deleted)
let initialStatus = bot.getStatus();
if (initialStatus.agentId) {
const exists = await agentExists(initialStatus.agentId);
if (!exists) {
console.log(`[Agent] Stored agent ${initialStatus.agentId} not found on server`);
bot.reset();
// Also clear env var so search-by-name can run
delete process.env.LETTA_AGENT_ID;
const gateway = new LettaGateway();
const services: {
cronServices: CronService[],
heartbeatServices: HeartbeatService[],
pollingServices: PollingService[],
groupBatchers: GroupBatcher[]
} = {
cronServices: [],
heartbeatServices: [],
pollingServices: [],
groupBatchers: [],
};
for (const agentConfig of agents) {
console.log(`\n[Setup] Configuring agent: ${agentConfig.name}`);
// Create LettaBot for this agent
const bot = new LettaBot({
workingDir: globalConfig.workingDir,
agentName: agentConfig.name,
allowedTools: globalConfig.allowedTools,
maxToolCalls: agentConfig.features?.maxToolCalls,
skills: {
cronEnabled: agentConfig.features?.cron ?? globalConfig.cronEnabled,
googleEnabled: !!agentConfig.integrations?.google?.enabled || !!agentConfig.polling?.gmail?.enabled,
},
});
// Apply explicit agent ID from config (before store verification)
let initialStatus = bot.getStatus();
if (agentConfig.id && !initialStatus.agentId) {
console.log(`[Agent:${agentConfig.name}] Using configured agent ID: ${agentConfig.id}`);
bot.setAgentId(agentConfig.id);
initialStatus = bot.getStatus();
}
}
// Container deploy: try to find existing agent by name if no ID set
const agentName = process.env.AGENT_NAME || 'LettaBot';
if (!initialStatus.agentId && isContainerDeploy) {
console.log(`[Agent] Searching for existing agent named "${agentName}"...`);
const found = await findAgentByName(agentName);
if (found) {
console.log(`[Agent] Found existing agent: ${found.id}`);
process.env.LETTA_AGENT_ID = found.id;
// Reinitialize bot with found agent
bot.setAgentId(found.id);
initialStatus = bot.getStatus();
// Verify agent exists (clear stale ID if deleted)
if (initialStatus.agentId) {
const exists = await agentExists(initialStatus.agentId);
if (!exists) {
console.log(`[Agent:${agentConfig.name}] Stored agent ${initialStatus.agentId} not found on server`);
bot.reset();
initialStatus = bot.getStatus();
}
}
}
// Agent will be created on first user message (lazy initialization)
if (!initialStatus.agentId) {
console.log(`[Agent] No agent found - will create "${agentName}" on first message`);
}
// Proactively disable tool approvals for headless operation
// Prevents stuck states from server-side requires_approval=true (SDK issue #25)
if (initialStatus.agentId) {
ensureNoToolApprovals(initialStatus.agentId).catch(err => {
console.warn('[Agent] Failed to check tool approvals:', err);
});
}
// Register enabled channels
if (config.telegram.enabled) {
const telegram = new TelegramAdapter({
token: config.telegram.token,
dmPolicy: config.telegram.dmPolicy,
allowedUsers: config.telegram.allowedUsers.length > 0 ? config.telegram.allowedUsers : undefined,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
});
bot.registerChannel(telegram);
}
if (config.slack.enabled) {
const slack = new SlackAdapter({
botToken: config.slack.botToken,
appToken: config.slack.appToken,
allowedUsers: config.slack.allowedUsers.length > 0 ? config.slack.allowedUsers : undefined,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
});
bot.registerChannel(slack);
}
if (config.whatsapp.enabled) {
if (!config.whatsapp.selfChatMode) {
console.warn('[WhatsApp] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!');
console.warn('[WhatsApp] Only use this if this is a dedicated bot number, not your personal WhatsApp.');
// Container deploy: discover by name
if (!initialStatus.agentId && isContainerDeploy) {
const found = await findAgentByName(agentConfig.name);
if (found) {
console.log(`[Agent:${agentConfig.name}] Found existing agent: ${found.id}`);
bot.setAgentId(found.id);
initialStatus = bot.getStatus();
}
}
const whatsapp = new WhatsAppAdapter({
sessionPath: config.whatsapp.sessionPath,
dmPolicy: config.whatsapp.dmPolicy,
allowedUsers: config.whatsapp.allowedUsers.length > 0 ? config.whatsapp.allowedUsers : undefined,
selfChatMode: config.whatsapp.selfChatMode,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
});
bot.registerChannel(whatsapp);
}
if (config.signal.enabled) {
if (!config.signal.selfChatMode) {
console.warn('[Signal] WARNING: selfChatMode is OFF - bot will respond to ALL incoming messages!');
console.warn('[Signal] Only use this if this is a dedicated bot number, not your personal Signal.');
if (!initialStatus.agentId) {
console.log(`[Agent:${agentConfig.name}] No agent found - will create on first message`);
}
const signal = new SignalAdapter({
phoneNumber: config.signal.phoneNumber,
cliPath: config.signal.cliPath,
httpHost: config.signal.httpHost,
httpPort: config.signal.httpPort,
dmPolicy: config.signal.dmPolicy,
allowedUsers: config.signal.allowedUsers.length > 0 ? config.signal.allowedUsers : undefined,
selfChatMode: config.signal.selfChatMode,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
// Disable tool approvals
if (initialStatus.agentId) {
ensureNoToolApprovals(initialStatus.agentId).catch(err => {
console.warn(`[Agent:${agentConfig.name}] Failed to check tool approvals:`, err);
});
}
// Create and register channels
const adapters = createChannelsForAgent(agentConfig, attachmentsDir, globalConfig.attachmentsMaxBytes);
for (const adapter of adapters) {
bot.registerChannel(adapter);
}
// Setup group batching
const { batcher, intervals, instantIds } = createGroupBatcher(agentConfig, bot);
if (batcher) {
bot.setGroupBatcher(batcher, intervals, instantIds);
services.groupBatchers.push(batcher);
}
// Per-agent cron
if (agentConfig.features?.cron ?? globalConfig.cronEnabled) {
const cronService = new CronService(bot);
await cronService.start();
services.cronServices.push(cronService);
}
// Per-agent heartbeat
const heartbeatConfig = agentConfig.features?.heartbeat;
const heartbeatService = new HeartbeatService(bot, {
enabled: heartbeatConfig?.enabled ?? false,
intervalMinutes: heartbeatConfig?.intervalMin ?? 30,
prompt: heartbeatConfig?.prompt || process.env.HEARTBEAT_PROMPT,
promptFile: heartbeatConfig?.promptFile,
workingDir: globalConfig.workingDir,
target: parseHeartbeatTarget(process.env.HEARTBEAT_TARGET),
});
bot.registerChannel(signal);
}
if (config.discord.enabled) {
const discord = new DiscordAdapter({
token: config.discord.token,
dmPolicy: config.discord.dmPolicy,
allowedUsers: config.discord.allowedUsers.length > 0 ? config.discord.allowedUsers : undefined,
attachmentsDir,
attachmentsMaxBytes: config.attachmentsMaxBytes,
});
bot.registerChannel(discord);
if (heartbeatConfig?.enabled) {
heartbeatService.start();
services.heartbeatServices.push(heartbeatService);
}
bot.onTriggerHeartbeat = () => heartbeatService.trigger();
// Per-agent polling
const pollConfig = agentConfig.polling || (agentConfig.integrations?.google ? {
enabled: agentConfig.integrations.google.enabled,
intervalMs: (agentConfig.integrations.google.pollIntervalSec || 60) * 1000,
gmail: {
enabled: agentConfig.integrations.google.enabled,
account: agentConfig.integrations.google.account || '',
},
} : undefined);
if (pollConfig?.enabled && pollConfig.gmail?.enabled) {
const pollingService = new PollingService(bot, {
intervalMs: pollConfig.intervalMs || 60000,
workingDir: globalConfig.workingDir,
gmail: {
enabled: pollConfig.gmail.enabled,
account: pollConfig.gmail.account || '',
},
});
pollingService.start();
services.pollingServices.push(pollingService);
}
gateway.addAgent(agentConfig.name, bot);
}
// Start cron service if enabled
// Note: CronService uses getDataDir() for cron-jobs.json to match the CLI
let cronService: CronService | null = null;
if (config.cronEnabled) {
cronService = new CronService(bot);
await cronService.start();
}
// Create heartbeat service (always available for /heartbeat command)
const heartbeatService = new HeartbeatService(bot, {
enabled: config.heartbeat.enabled,
intervalMinutes: config.heartbeat.intervalMinutes,
prompt: config.heartbeat.prompt,
workingDir: config.workingDir,
target: config.heartbeat.target,
});
// Start auto-heartbeats only if interval is configured
if (config.heartbeat.enabled) {
heartbeatService.start();
}
// Wire up /heartbeat command (always available)
bot.onTriggerHeartbeat = () => heartbeatService.trigger();
// Start polling service if enabled (Gmail, etc.)
let pollingService: PollingService | null = null;
if (config.polling.enabled) {
pollingService = new PollingService(bot, {
intervalMs: config.polling.intervalMs,
workingDir: config.workingDir,
gmail: config.polling.gmail,
});
pollingService.start();
}
// Start all channels
await bot.start();
// Start all agents
await gateway.start();
// Load/generate API key for CLI authentication
const apiKey = loadOrGenerateApiKey();
console.log(`[API] Key: ${apiKey.slice(0, 8)}... (set LETTABOT_API_KEY to customize)`);
// Start API server (replaces health server, includes health checks)
// Provides endpoints for CLI to send messages across Docker boundaries
// Start API server - uses gateway for delivery
const apiPort = parseInt(process.env.PORT || '8080', 10);
const apiHost = process.env.API_HOST; // undefined = 127.0.0.1 (secure default)
const apiCorsOrigin = process.env.API_CORS_ORIGIN; // undefined = same-origin only
const apiServer = createApiServer(bot, {
const apiServer = createApiServer(gateway, {
port: apiPort,
apiKey: apiKey,
host: apiHost,
corsOrigin: apiCorsOrigin,
});
// Log status
const status = bot.getStatus();
// Status logging
console.log('\n=================================');
console.log('LettaBot is running!');
console.log(`LettaBot is running! (${gateway.size} agent${gateway.size > 1 ? 's' : ''})`);
console.log('=================================');
console.log(`Agent ID: ${status.agentId || '(will be created on first message)'}`);
if (isContainerDeploy && status.agentId) {
console.log(`[Agent] Using agent "${agentName}" (auto-discovered by name)`);
}
console.log(`Channels: ${status.channels.join(', ')}`);
console.log(`Cron: ${config.cronEnabled ? 'enabled' : 'disabled'}`);
console.log(`Heartbeat: ${config.heartbeat.enabled ? `every ${config.heartbeat.intervalMinutes} min` : 'disabled'}`);
console.log(`Polling: ${config.polling.enabled ? `every ${config.polling.intervalMs / 1000}s` : 'disabled'}`);
if (config.polling.gmail.enabled) {
console.log(` └─ Gmail: ${config.polling.gmail.account}`);
}
if (config.heartbeat.enabled) {
console.log(`Heartbeat target: ${config.heartbeat.target ? `${config.heartbeat.target.channel}:${config.heartbeat.target.chatId}` : 'last messaged'}`);
for (const name of gateway.getAgentNames()) {
const status = gateway.getAgent(name)!.getStatus();
console.log(` ${name}: ${status.agentId || '(pending)'} [${status.channels.join(', ')}]`);
}
console.log('=================================\n');
// Handle shutdown
// Shutdown
const shutdown = async () => {
console.log('\nShutting down...');
heartbeatService?.stop();
cronService?.stop();
await bot.stop();
services.groupBatchers.forEach(b => b.stop());
services.heartbeatServices.forEach(h => h.stop());
services.cronServices.forEach(c => c.stop());
services.pollingServices.forEach(p => p.stop());
await gateway.stop();
process.exit(0);
};

View File

@@ -0,0 +1,68 @@
/**
* Approved Groups Store
*
* Tracks which groups have been approved (activated by a paired user).
* Only relevant when dmPolicy === 'pairing'.
*
* Storage: ~/.lettabot/credentials/{channel}-approvedGroups.json
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
interface ApprovedGroupsStore {
version: 1;
groups: string[];
}
function getCredentialsDir(): string {
return path.join(os.homedir(), '.lettabot', 'credentials');
}
function getStorePath(channel: string): string {
return path.join(getCredentialsDir(), `${channel}-approvedGroups.json`);
}
async function ensureDir(dir: string): Promise<void> {
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
}
async function readJson<T>(filePath: string, fallback: T): Promise<T> {
try {
const raw = await fs.promises.readFile(filePath, 'utf-8');
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
async function writeJson(filePath: string, data: unknown): Promise<void> {
await ensureDir(path.dirname(filePath));
const tmp = `${filePath}.${crypto.randomUUID()}.tmp`;
await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8' });
await fs.promises.chmod(tmp, 0o600);
await fs.promises.rename(tmp, filePath);
}
/**
* Check if a group has been approved for a given channel.
*/
export async function isGroupApproved(channel: string, chatId: string): Promise<boolean> {
const filePath = getStorePath(channel);
const store = await readJson<ApprovedGroupsStore>(filePath, { version: 1, groups: [] });
return (store.groups || []).includes(chatId);
}
/**
* Approve a group for a given channel.
*/
export async function approveGroup(channel: string, chatId: string): Promise<void> {
const filePath = getStorePath(channel);
const store = await readJson<ApprovedGroupsStore>(filePath, { version: 1, groups: [] });
const groups = store.groups || [];
if (groups.includes(chatId)) return;
groups.push(chatId);
await writeJson(filePath, { version: 1, groups });
}

View File

@@ -8,7 +8,7 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { LettaBot } from '../core/bot.js';
import type { AgentSession } from '../core/interfaces.js';
export interface PollingConfig {
intervalMs: number; // Polling interval in milliseconds
@@ -21,14 +21,14 @@ export interface PollingConfig {
export class PollingService {
private intervalId: ReturnType<typeof setInterval> | null = null;
private bot: LettaBot;
private bot: AgentSession;
private config: PollingConfig;
// Track seen email IDs to detect new emails (persisted to disk)
private seenEmailIds: Set<string> = new Set();
private seenEmailsPath: string;
constructor(bot: LettaBot, config: PollingConfig) {
constructor(bot: AgentSession, config: PollingConfig) {
this.bot = bot;
this.config = config;
this.seenEmailsPath = join(config.workingDir, 'seen-emails.json');