From 16b5e5b7b73a9e5b3b0154101ba0cbae14ef02b8 Mon Sep 17 00:00:00 2001 From: Jason Carreira Date: Mon, 9 Feb 2026 13:16:10 -0500 Subject: [PATCH] Add lettabot-history CLI (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add lettabot-history CLI * Document and test lettabot-history * Validate lettabot-history limit * fix: address review feedback on history CLI - Extract shared loadLastTarget into cli/shared.ts (was duplicated in message.ts, react.ts, history-core.ts) - Clamp --limit to platform maximums (Discord: 100, Slack: 1000) - Fix Discord author formatting: use globalName/username instead of deprecated discriminator - Add Slack fetch test Written by Cameron ◯ Letta Code "You miss 100% of the shots you don't take." -- Wayne Gretzky -- Michael Scott --------- Co-authored-by: Jason Carreira Co-authored-by: Cameron --- README.md | 12 +++ docs/README.md | 1 + docs/cli-tools.md | 36 +++++++++ package.json | 1 + src/cli/history-core.test.ts | 141 +++++++++++++++++++++++++++++++++ src/cli/history-core.ts | 146 +++++++++++++++++++++++++++++++++++ src/cli/history.ts | 100 ++++++++++++++++++++++++ src/cli/message.ts | 29 +------ src/cli/react.ts | 29 +------ src/cli/shared.ts | 28 +++++++ 10 files changed, 467 insertions(+), 56 deletions(-) create mode 100644 docs/cli-tools.md create mode 100644 src/cli/history-core.test.ts create mode 100644 src/cli/history-core.ts create mode 100644 src/cli/history.ts create mode 100644 src/cli/shared.ts 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/package.json b/package.json index c263b10..5538981 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "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": { diff --git a/src/cli/history-core.test.ts b/src/cli/history-core.test.ts new file mode 100644 index 0000000..8040cc8 --- /dev/null +++ b/src/cli/history-core.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchDiscordHistory, fetchHistory, fetchSlackHistory, isValidLimit, parseFetchArgs } from './history-core.js'; +import { loadLastTarget } from './shared.js'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +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); + }); +}); + +describe('loadLastTarget', () => { + it('loads the last message target from the store path', () => { + const dir = mkdtempSync(join(tmpdir(), 'lettabot-history-')); + const storePath = join(dir, 'lettabot-agent.json'); + writeFileSync( + storePath, + JSON.stringify({ lastMessageTarget: { channel: 'slack', chatId: 'C123' } }), + 'utf-8' + ); + + const target = loadLastTarget(storePath); + expect(target).toEqual({ channel: 'slack', chatId: 'C123' }); + + rmSync(dir, { recursive: true, force: true }); + }); +}); + +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..4d81ae8 --- /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, STORE_PATH } 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(STORE_PATH); + 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..cf55ed4 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, STORE_PATH } 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..a5e94e9 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, STORE_PATH } 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..87cc8af --- /dev/null +++ b/src/cli/shared.ts @@ -0,0 +1,28 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { getDataDir } from '../utils/paths.js'; + +export interface LastTarget { + channel: string; + chatId: string; + messageId?: string; +} + +interface AgentStore { + agentId?: string; + lastMessageTarget?: LastTarget; +} + +export const STORE_PATH = resolve(getDataDir(), 'lettabot-agent.json'); + +export function loadLastTarget(storePath: string = STORE_PATH): LastTarget | null { + try { + if (existsSync(storePath)) { + const store: AgentStore = JSON.parse(readFileSync(storePath, 'utf-8')); + return store.lastMessageTarget || null; + } + } catch { + // Ignore + } + return null; +}