#!/usr/bin/env node /** * lettabot-message - Send messages to channels * * Usage: * lettabot-message send --text "Hello!" [--channel telegram] [--chat 123456] * lettabot-message send -t "Hello!" * * The agent can use this CLI via Bash to send messages during silent mode * (heartbeats, cron jobs) or to send to different channels during conversations. */ // Config loaded from lettabot.yaml import { loadAppConfigOrExit, applyConfigToEnv } from '../config/index.js'; import { loadApiKey } from '../api/auth.js'; const config = loadAppConfigOrExit(); applyConfigToEnv(config); import { existsSync, readFileSync } from 'node:fs'; import { loadLastTarget } from './shared.js'; // Channel senders async function sendTelegram(chatId: string, text: string): Promise { const token = process.env.TELEGRAM_BOT_TOKEN; if (!token) { throw new Error('TELEGRAM_BOT_TOKEN not set'); } const url = `https://api.telegram.org/bot${token}/sendMessage`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text: text, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Telegram API error: ${error}`); } const result = await response.json() as { ok: boolean; result?: { message_id: number } }; if (!result.ok) { throw new Error(`Telegram API returned ok=false`); } console.log(`✓ Sent to telegram:${chatId} (message_id: ${result.result?.message_id})`); } async function sendSlack(chatId: string, text: string): Promise { const token = process.env.SLACK_BOT_TOKEN; if (!token) { throw new Error('SLACK_BOT_TOKEN not set'); } // Slack uses mrkdwn, which differs slightly from standard Markdown. // Convert for correct formatting (bold, italics, links, code fences, etc.). const { markdownToSlackMrkdwn } = await import('../channels/slack-format.js'); const formatted = await markdownToSlackMrkdwn(text); const response = await fetch('https://slack.com/api/chat.postMessage', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ channel: chatId, text: formatted, }), }); const result = await response.json() as { ok: boolean; ts?: string; error?: string }; if (!result.ok) { throw new Error(`Slack API error: ${result.error}`); } console.log(`✓ Sent to slack:${chatId} (ts: ${result.ts})`); } async function sendSignal(chatId: string, text: string): Promise { // We talk to the signal-cli daemon JSON-RPC API (the same daemon the Signal adapter uses). // This is *not* the signal-cli-rest-api container. const apiUrl = process.env.SIGNAL_CLI_REST_API_URL || 'http://127.0.0.1:8090'; const phoneNumber = process.env.SIGNAL_PHONE_NUMBER; if (!phoneNumber) { throw new Error('SIGNAL_PHONE_NUMBER not set'); } // Support group IDs in the same format we use everywhere else. const params: Record = { account: phoneNumber, message: text, }; if (chatId.startsWith('group:')) { params.groupId = chatId.slice('group:'.length); } else { params.recipient = [chatId]; } const body = JSON.stringify({ jsonrpc: '2.0', method: 'send', params, id: Date.now(), }); const response = await fetch(`${apiUrl}/api/v1/rpc`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }); // signal-cli returns status 201 with empty body sometimes. if (response.status === 201) { console.log(`✓ Sent to signal:${chatId}`); return; } const textBody = await response.text(); if (!response.ok) { throw new Error(`Signal API error: ${textBody}`); } if (!textBody.trim()) { console.log(`✓ Sent to signal:${chatId}`); return; } const parsed = JSON.parse(textBody) as { result?: unknown; error?: { code?: number; message?: string } }; if (parsed.error) { throw new Error(`Signal RPC ${parsed.error.code ?? 'unknown'}: ${parsed.error.message ?? 'unknown error'}`); } console.log(`✓ Sent to signal:${chatId}`); } /** * Send message or file via API (unified multipart endpoint) */ async function sendViaApi( channel: string, chatId: string, options: { text?: string; filePath?: string; kind?: 'image' | 'file' | 'audio'; } ): Promise { const apiUrl = process.env.LETTABOT_API_URL || 'http://localhost:8080'; // Resolve API key: env var > lettabot-api.json (never generate -- that's the server's job) const apiKey = loadApiKey(); // Check if file exists if (options.filePath && !existsSync(options.filePath)) { throw new Error(`File not found: ${options.filePath}`); } // Everything uses multipart now (Option B) const formData = new FormData(); formData.append('channel', channel); formData.append('chatId', chatId); if (options.text) { formData.append('text', options.text); } if (options.filePath) { const fileContent = readFileSync(options.filePath); const fileName = options.filePath.split('/').pop() || 'file'; formData.append('file', new Blob([fileContent]), fileName); } if (options.kind) { formData.append('kind', options.kind); } const response = await fetch(`${apiUrl}/api/v1/messages`, { method: 'POST', headers: { 'X-Api-Key': apiKey, }, body: formData, }); if (!response.ok) { const error = await response.text(); throw new Error(`API error (${response.status}): ${error}`); } const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Unknown error'); } const type = options.filePath ? 'file' : 'message'; console.log(`✓ Sent ${type} to ${channel}:${chatId}`); } async function sendWhatsApp(chatId: string, text: string): Promise { return sendViaApi('whatsapp', chatId, { text }); } async function sendDiscord(chatId: string, text: string): Promise { const token = process.env.DISCORD_BOT_TOKEN; if (!token) { throw new Error('DISCORD_BOT_TOKEN not set'); } const response = await fetch(`https://discord.com/api/v10/channels/${chatId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bot ${token}`, }, body: JSON.stringify({ content: text }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Discord API error: ${error}`); } const result = await response.json() as { id?: string }; console.log(`✓ Sent to discord:${chatId} (id: ${result.id || 'unknown'})`); } async function sendBluesky(text: string): Promise { const handle = process.env.BLUESKY_HANDLE; const appPassword = process.env.BLUESKY_APP_PASSWORD; const serviceUrl = (process.env.BLUESKY_SERVICE_URL || 'https://bsky.social').replace(/\/+$/, ''); if (!handle || !appPassword) { throw new Error('BLUESKY_HANDLE/BLUESKY_APP_PASSWORD not set'); } const sessionRes = await fetch(`${serviceUrl}/xrpc/com.atproto.server.createSession`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: handle, password: appPassword }), }); if (!sessionRes.ok) { const detail = await sessionRes.text(); throw new Error(`Bluesky createSession failed: ${detail}`); } const session = await sessionRes.json() as { accessJwt: string; did: string }; const chars = Array.from(text); const trimmed = chars.length > 300 ? chars.slice(0, 300).join('') : text; if (!trimmed.trim()) { throw new Error('Bluesky post text is empty'); } const record = { text: trimmed, createdAt: new Date().toISOString(), }; const postRes = await fetch(`${serviceUrl}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.accessJwt}`, }, body: JSON.stringify({ repo: session.did, collection: 'app.bsky.feed.post', record, }), }); if (!postRes.ok) { const detail = await postRes.text(); throw new Error(`Bluesky createRecord failed: ${detail}`); } const result = await postRes.json() as { uri?: string }; console.log(`✓ Sent to bluesky (uri: ${result.uri || 'unknown'})`); } async function sendToChannel(channel: string, chatId: string, text: string): Promise { switch (channel.toLowerCase()) { case 'telegram': return sendTelegram(chatId, text); case 'slack': return sendSlack(chatId, text); case 'signal': return sendSignal(chatId, text); case 'whatsapp': return sendWhatsApp(chatId, text); case 'discord': return sendDiscord(chatId, text); case 'bluesky': return sendBluesky(text); default: throw new Error(`Unknown channel: ${channel}. Supported: telegram, slack, signal, whatsapp, discord, bluesky`); } } // Command handlers async function sendCommand(args: string[]): Promise { let text = ''; let filePath = ''; let kind: 'image' | 'file' | 'audio' | undefined = undefined; let channel = ''; let chatId = ''; const fileCapableChannels = new Set(['telegram', 'slack', 'discord', 'whatsapp']); // Parse args for (let i = 0; i < args.length; i++) { const arg = args[i]; const next = args[i + 1]; if ((arg === '--text' || arg === '-t') && next) { text = next; i++; } else if ((arg === '--file' || arg === '-f') && next) { filePath = next; i++; } else if (arg === '--image') { kind = 'image'; } else if (arg === '--voice') { kind = 'audio'; } else if ((arg === '--channel' || arg === '-c' || arg === '-C') && next) { channel = next; i++; } else if ((arg === '--chat' || arg === '--to') && next) { chatId = next; i++; } } // Check if text OR file provided if (!text && !filePath) { console.error('Error: --text or --file is required'); console.error('Usage: lettabot-message send --text "..." OR --file path.pdf [--text "caption"]'); process.exit(1); } // Resolve defaults from last target if (!channel || (!chatId && channel !== 'bluesky')) { const lastTarget = loadLastTarget(); if (lastTarget) { if (!channel) { channel = lastTarget.channel; } if (!chatId && channel !== 'bluesky') { chatId = lastTarget.chatId; } } } if (!channel) { console.error('Error: --channel is required (no default available)'); console.error('Specify: --channel telegram|slack|signal|discord|whatsapp|bluesky'); process.exit(1); } if (!chatId && channel !== 'bluesky') { console.error('Error: --chat is required (no default available)'); console.error('Specify: --chat '); process.exit(1); } try { if (filePath) { if (!fileCapableChannels.has(channel)) { throw new Error(`File sending not supported for ${channel}. Supported: telegram, slack, discord, whatsapp`); } await sendViaApi(channel, chatId, { text, filePath, kind }); return; } // Text-only: direct platform APIs (WhatsApp uses API internally) await sendToChannel(channel, chatId, text); } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } } function showHelp(): void { console.log(` lettabot-message - Send messages or files to channels Commands: send [options] Send a message or file Send options: --text, -t Message text (or caption when used with --file) --file, -f File path (optional, for file messages) --image Treat file as image (vs document) --voice Treat file as voice note (sends as native voice memo) --channel, -c Channel: telegram, slack, whatsapp, discord, bluesky (default: last used) --chat, --to Chat/conversation ID (default: last messaged; not required for bluesky) Examples: # Send text message lettabot-message send --text "Hello!" # Send file with caption/text lettabot-message send --file screenshot.png --text "Check this out" # Send file without text lettabot-message send --file photo.jpg --image # Send to specific WhatsApp chat lettabot-message send --file report.pdf --text "Report attached" --channel whatsapp --chat "+1555@s.whatsapp.net" # Send voice note lettabot-message send --file voice.ogg --voice # Short form lettabot-message send -t "Done!" -f doc.pdf -c telegram Environment variables: TELEGRAM_BOT_TOKEN Required for Telegram SLACK_BOT_TOKEN Required for Slack DISCORD_BOT_TOKEN Required for Discord SIGNAL_PHONE_NUMBER Required for Signal (text only, no files) LETTABOT_API_KEY Override API key (auto-read from lettabot-api.json if not set) BLUESKY_HANDLE Required for Bluesky posts BLUESKY_APP_PASSWORD Required for Bluesky posts BLUESKY_SERVICE_URL Optional override (default https://bsky.social) LETTABOT_API_URL API server URL (default: http://localhost:8080) SIGNAL_CLI_REST_API_URL Signal daemon URL (default: http://127.0.0.1:8090) Note: File sending uses the API server for supported channels (telegram, slack, discord, whatsapp). Text-only messages use direct platform APIs (WhatsApp uses API). `); } // Main const args = process.argv.slice(2); const command = args[0]; switch (command) { case 'send': sendCommand(args.slice(1)); break; case 'help': case '--help': case '-h': showHelp(); break; default: if (command) { // Assume it's send with args starting with the command // e.g., `lettabot-message --text "Hi"` (no 'send' subcommand) if (command.startsWith('-')) { sendCommand(args); break; } console.error(`Unknown command: ${command}`); } showHelp(); break; }