Files
lettabot/src/cli/message.ts

466 lines
14 KiB
JavaScript

#!/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<void> {
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<void> {
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<void> {
// 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<string, unknown> = {
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<void> {
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<void> {
return sendViaApi('whatsapp', chatId, { text });
}
async function sendDiscord(chatId: string, text: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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 <chat_id>');
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 <text> Message text (or caption when used with --file)
--file, -f <path> 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 <name> Channel: telegram, slack, whatsapp, discord, bluesky (default: last used)
--chat, --to <id> 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;
}