diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b74fb0a..67c81fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/README.md b/README.md index f224009..3390dfc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/README.md b/docs/README.md index 0a532cf..f7a262b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/cli-tools.md b/docs/cli-tools.md new file mode 100644 index 0000000..5b06196 --- /dev/null +++ b/docs/cli-tools.md @@ -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`). diff --git a/docs/configuration.md b/docs/configuration.md index 0e0be82..0520705 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 ` 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: + +``` + +``` + +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 `` 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 ``, 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. diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md index d378a44..fa60222 100644 --- a/docs/telegram-setup.md +++ b/docs/telegram-setup.md @@ -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: diff --git a/package.json b/package.json index c8ada88..5538981 100644 --- a/package.json +++ b/package.json @@ -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 ", "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", diff --git a/src/api/server.ts b/src/api/server.ts index 443f3dc..e0855f7 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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, { diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 1a0e6ff..e6de0ae 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -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 }).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 }).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, diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 389885b..57fadee 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -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}`); diff --git a/src/channels/slack.ts b/src/channels/slack.ts index bb54840..1c4d41b 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -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 { // Slack doesn't have a typing indicator API for bots // This is a no-op diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index a54d63e..2500829 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -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 { 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 { @@ -409,6 +521,10 @@ export class TelegramAdapter implements ChannelAdapter { ]); } + getDmPolicy(): string { + return this.config.dmPolicy || 'pairing'; + } + async sendTypingIndicator(chatId: string): Promise { 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(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; +} diff --git a/src/channels/types.ts b/src/channels/types.ts index ebcc40e..899a695 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -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; diff --git a/src/channels/whatsapp/index.ts b/src/channels/whatsapp/index.ts index 470d624..42953fb 100644 --- a/src/channels/whatsapp/index.ts +++ b/src/channels/whatsapp/index.ts @@ -977,6 +977,10 @@ export class WhatsAppAdapter implements ChannelAdapter { ); } + getDmPolicy(): string { + return this.config.dmPolicy || 'pairing'; + } + supportsEditing(): boolean { return false; } diff --git a/src/cli/history-core.test.ts b/src/cli/history-core.test.ts new file mode 100644 index 0000000..7fb842d --- /dev/null +++ b/src/cli/history-core.test.ts @@ -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'); + }); +}); diff --git a/src/cli/history-core.ts b/src/cli/history-core.ts new file mode 100644 index 0000000..671ee69 --- /dev/null +++ b/src/cli/history-core.ts @@ -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 { + 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 { + 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 { + 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`); + } +} diff --git a/src/cli/history.ts b/src/cli/history.ts new file mode 100644 index 0000000..98a1f57 --- /dev/null +++ b/src/cli/history.ts @@ -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 { + 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 '); + 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 Max messages (default: 50) + --channel, -c Channel: discord, slack + --chat, --to Chat/conversation ID (default: last messaged) + --before, -b 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 { + 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); +}); diff --git a/src/cli/message.ts b/src/cli/message.ts index 9d39636..2cf6faf 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -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 { diff --git a/src/cli/react.ts b/src/cli/react.ts index 30015a8..b42882d 100644 --- a/src/cli/react.ts +++ b/src/cli/react.ts @@ -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 = { eyes: 'šŸ‘€', diff --git a/src/cli/shared.ts b/src/cli/shared.ts new file mode 100644 index 0000000..e3393b6 --- /dev/null +++ b/src/cli/shared.ts @@ -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; +} diff --git a/src/config/io.ts b/src/config/io.ts index 96c56c1..bdb3855 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -58,7 +58,12 @@ export function loadConfig(): LettaBotConfig { try { const content = readFileSync(configPath, 'utf-8'); const parsed = YAML.parse(content) as Partial; - + + // 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 { 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 { 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 { 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 { 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 { 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 { 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 { } } } + +/** + * 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): 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)); + } + } + } +} diff --git a/src/config/normalize.test.ts b/src/config/normalize.test.ts new file mode 100644 index 0000000..7880986 --- /dev/null +++ b/src/config/normalize.test.ts @@ -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 = {}; + + 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); + }); +}); diff --git a/src/config/types.ts b/src/config/types.ts index 8104840..6918e00 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; + 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; // 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, + }]; +} diff --git a/src/core/bot.ts b/src/core/bot.ts index facf6c5..4d0bb9b 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -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 { + // 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 = new Map(); @@ -41,6 +89,9 @@ export class LettaBot { // Callback to trigger heartbeat (set by main.ts) public onTriggerHeartbeat?: () => Promise; + private groupBatcher?: GroupBatcher; + private groupIntervals: Map = new Map(); // channel -> intervalMin + private instantGroupIds: Set = 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, instantGroupIds?: Set): 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 { 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 = {}; - // 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(); 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; diff --git a/src/core/formatter.ts b/src/core/formatter.ts index 2cdaf45..18851e5 100644 --- a/src/core/formatter.ts +++ b/src/core/formatter.ts @@ -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}`; +} diff --git a/src/core/gateway.test.ts b/src/core/gateway.test.ts new file mode 100644 index 0000000..c347431 --- /dev/null +++ b/src/core/gateway.test.ts @@ -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(); + }); +}); diff --git a/src/core/gateway.ts b/src/core/gateway.ts new file mode 100644 index 0000000..802700c --- /dev/null +++ b/src/core/gateway.ts @@ -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 = 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 { + 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 { + 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 { + // 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}`); + } +} diff --git a/src/core/group-batcher.ts b/src/core/group-batcher.ts new file mode 100644 index 0000000..0f9abcd --- /dev/null +++ b/src/core/group-batcher.ts @@ -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 | null; +} + +export type OnFlushCallback = (msg: InboundMessage, adapter: ChannelAdapter) => void; + +export class GroupBatcher { + private buffer: Map = 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(); + } +} diff --git a/src/core/index.ts b/src/core/index.ts index 41d9732..e33ddae 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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'; diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts new file mode 100644 index 0000000..b2ce883 --- /dev/null +++ b/src/core/interfaces.ts @@ -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, instantGroupIds?: Set): void; + + /** Process a batched group message */ + processGroupBatch(msg: InboundMessage, adapter: ChannelAdapter): void; + + /** Start all registered channels */ + start(): Promise; + + /** Stop all channels */ + stop(): Promise; + + /** Send a message to the agent (used by cron, heartbeat, polling) */ + sendToAgent(text: string, context?: TriggerContext): Promise; + + /** Deliver a message/file to a specific channel */ + deliverToChannel(channelId: string, chatId: string, options: { + text?: string; + filePath?: string; + kind?: 'image' | 'file'; + }): Promise; + + /** 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; +} + +/** + * 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; +} diff --git a/src/core/prompts.ts b/src/core/prompts.ts index 21b4db4..922a6f9 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -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 */ diff --git a/src/core/store.test.ts b/src/core/store.test.ts new file mode 100644 index 0000000..3466512 --- /dev/null +++ b/src/core/store.test.ts @@ -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(); + }); +}); diff --git a/src/core/store.ts b/src/core/store.ts index 4a38f36..329c197 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -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; +} + 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(); } } diff --git a/src/core/stream-watchdog.test.ts b/src/core/stream-watchdog.test.ts deleted file mode 100644 index a33c8d5..0000000 --- a/src/core/stream-watchdog.test.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/src/core/stream-watchdog.ts b/src/core/stream-watchdog.ts deleted file mode 100644 index 043131d..0000000 --- a/src/core/stream-watchdog.ts +++ /dev/null @@ -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); - } -} diff --git a/src/core/types.ts b/src/core/types.ts index d25cd97..66a076e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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) } /** diff --git a/src/cron/heartbeat.test.ts b/src/cron/heartbeat.test.ts new file mode 100644 index 0000000..df93967 --- /dev/null +++ b/src/cron/heartbeat.test.ts @@ -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 { + 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).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).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).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).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).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).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).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).mock.calls[0][0] as string; + // Empty/whitespace file should fall back to default + expect(sentMessage).toContain('This is your time'); + }); +}); diff --git a/src/cron/heartbeat.ts b/src/cron/heartbeat.ts index 494405d..7692224 100644 --- a/src/cron/heartbeat.ts +++ b/src/cron/heartbeat.ts @@ -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`); diff --git a/src/cron/service.ts b/src/cron/service.ts index f9fbb46..c46ea88 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -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 = new Map(); private scheduledJobs: Map = 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 diff --git a/src/main.ts b/src/main.ts index 50a5021..4ab4917 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 ` 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 { // 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; instantIds: Set } { + const intervals = new Map(); + const instantIds = new Set(); + + // 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); }; diff --git a/src/pairing/group-store.ts b/src/pairing/group-store.ts new file mode 100644 index 0000000..921c377 --- /dev/null +++ b/src/pairing/group-store.ts @@ -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 { + await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); +} + +async function readJson(filePath: string, fallback: T): Promise { + 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 { + 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 { + const filePath = getStorePath(channel); + const store = await readJson(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 { + const filePath = getStorePath(channel); + const store = await readJson(filePath, { version: 1, groups: [] }); + const groups = store.groups || []; + if (groups.includes(chatId)) return; + groups.push(chatId); + await writeJson(filePath, { version: 1, groups }); +} diff --git a/src/polling/service.ts b/src/polling/service.ts index 2326dab..7dd8c37 100644 --- a/src/polling/service.ts +++ b/src/polling/service.ts @@ -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 | 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 = 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');