466 lines
14 KiB
JavaScript
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;
|
|
}
|