diff --git a/package.json b/package.json index 0c6dcf2..c935732 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "bin": { "lettabot": "./dist/cli.js", "lettabot-schedule": "./dist/cron/cli.js", - "lettabot-message": "./dist/cli/message.js" + "lettabot-message": "./dist/cli/message.js", + "lettabot-react": "./dist/cli/react.js" }, "scripts": { "setup": "tsx src/setup.ts", diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 70762cc..ff19519 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -195,6 +195,7 @@ Ask the bot owner to approve with: userId, userName: displayName, userHandle: message.author.username, + messageId: message.id, text: content, timestamp: message.createdAt, isGroup, diff --git a/src/channels/slack.ts b/src/channels/slack.ts index a9ed167..76cee96 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -72,6 +72,7 @@ export class SlackAdapter implements ChannelAdapter { chatId: channelId, userId: userId || '', userHandle: userId || '', // Slack user ID serves as handle + messageId: message.ts || undefined, text: text || '', timestamp: new Date(Number(message.ts) * 1000), threadId: threadTs, @@ -104,6 +105,7 @@ export class SlackAdapter implements ChannelAdapter { chatId: channelId, userId: userId || '', userHandle: userId || '', // Slack user ID serves as handle + messageId: event.ts || undefined, text: text || '', timestamp: new Date(Number(event.ts) * 1000), threadId: threadTs, diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 18f9981..76598e3 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -161,6 +161,7 @@ export class TelegramAdapter implements ChannelAdapter { chatId: String(chatId), userId: String(userId), userName: ctx.from.username || ctx.from.first_name, + messageId: String(ctx.message.message_id), text, timestamp: new Date(), }); diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts index bee4605..3f12ca9 100644 --- a/src/channels/whatsapp.ts +++ b/src/channels/whatsapp.ts @@ -302,6 +302,7 @@ Ask the bot owner to approve with: chatId: remoteJid, userId, userName: pushName || undefined, + messageId: m.key?.id || undefined, text, timestamp: new Date(m.messageTimestamp * 1000), isGroup, diff --git a/src/cli/react.ts b/src/cli/react.ts new file mode 100644 index 0000000..1562edf --- /dev/null +++ b/src/cli/react.ts @@ -0,0 +1,274 @@ +#!/usr/bin/env node +/** + * lettabot-react - Add reactions to messages + * + * Usage: + * lettabot-react add --emoji "👀" [--channel telegram] [--chat 123456] [--message 789] + * lettabot-react add --emoji :eyes: + * + * The agent can use this CLI via Bash to react to messages. + */ + +// Config loaded from lettabot.yaml +import { loadConfig, applyConfigToEnv } from '../config/index.js'; +const config = loadConfig(); +applyConfigToEnv(config); +import { resolve } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; + +interface LastTarget { + channel: string; + chatId: string; + messageId?: string; +} + +interface AgentStore { + agentId?: string; + lastMessageTarget?: LastTarget; +} + +const STORE_PATH = resolve(process.cwd(), '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; +} + +const EMOJI_ALIAS_TO_UNICODE: Record = { + eyes: '👀', + thumbsup: '👍', + thumbs_up: '👍', + '+1': '👍', + heart: '❤️', + fire: '🔥', + smile: '😄', + laughing: '😆', + tada: '🎉', + clap: '👏', + ok_hand: '👌', +}; + +const UNICODE_TO_ALIAS = new Map( + Object.entries(EMOJI_ALIAS_TO_UNICODE).map(([name, value]) => [value, name]) +); + +function parseAlias(input: string): string | null { + const match = input.match(/^:([^:]+):$/); + return match ? match[1] : null; +} + +function resolveEmoji(input: string): { unicode?: string; slackName?: string } { + const alias = parseAlias(input); + if (alias) { + return { unicode: EMOJI_ALIAS_TO_UNICODE[alias], slackName: alias }; + } + return { unicode: input, slackName: UNICODE_TO_ALIAS.get(input) }; +} + +async function addTelegramReaction(chatId: string, messageId: string, emoji: string): Promise { + const token = process.env.TELEGRAM_BOT_TOKEN; + if (!token) throw new Error('TELEGRAM_BOT_TOKEN not set'); + + const response = await fetch(`https://api.telegram.org/bot${token}/setMessageReaction`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + message_id: Number(messageId), + reaction: [{ type: 'emoji', emoji }], + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Telegram API error: ${error}`); + } +} + +async function addSlackReaction(chatId: string, messageId: string, name: string): Promise { + 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/reactions.add', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + channel: chatId, + name, + timestamp: messageId, + }), + }); + + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + throw new Error(`Slack API error: ${result.error || 'unknown error'}`); + } +} + +async function addDiscordReaction(chatId: string, messageId: string, emoji: string): Promise { + const token = process.env.DISCORD_BOT_TOKEN; + if (!token) throw new Error('DISCORD_BOT_TOKEN not set'); + + const encoded = encodeURIComponent(emoji); + const response = await fetch( + `https://discord.com/api/v10/channels/${chatId}/messages/${messageId}/reactions/${encoded}/@me`, + { + method: 'PUT', + headers: { + 'Authorization': `Bot ${token}`, + }, + } + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Discord API error: ${error}`); + } +} + +async function addReaction(channel: string, chatId: string, messageId: string, emoji: string): Promise { + const { unicode, slackName } = resolveEmoji(emoji); + const channelName = channel.toLowerCase(); + + switch (channelName) { + case 'telegram': { + if (!unicode) throw new Error('Unknown emoji alias for Telegram'); + return addTelegramReaction(chatId, messageId, unicode); + } + case 'slack': { + const name = slackName || parseAlias(emoji)?.replace(/:/g, '') || ''; + if (!name) throw new Error('Unknown emoji alias for Slack'); + return addSlackReaction(chatId, messageId, name); + } + case 'discord': { + if (!unicode) throw new Error('Unknown emoji alias for Discord'); + return addDiscordReaction(chatId, messageId, unicode); + } + default: + throw new Error(`Unknown channel: ${channel}. Supported: telegram, slack, discord`); + } +} + +async function addCommand(args: string[]): Promise { + let emoji = ''; + let channel = ''; + let chatId = ''; + let messageId = ''; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + + if ((arg === '--emoji' || arg === '-e') && next) { + emoji = 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 === '--message' || arg === '--message-id' || arg === '-m') && next) { + messageId = next; + i++; + } + } + + if (!emoji) { + console.error('Error: --emoji is required'); + console.error('Usage: lettabot-react add --emoji "👀" [--channel telegram] [--chat 123456] [--message 789]'); + process.exit(1); + } + + if (!channel || !chatId || !messageId) { + const lastTarget = loadLastTarget(); + if (lastTarget) { + channel = channel || lastTarget.channel; + chatId = chatId || lastTarget.chatId; + messageId = messageId || lastTarget.messageId || ''; + } + } + + if (!channel) { + console.error('Error: --channel is required (no default available)'); + console.error('Specify: --channel telegram|slack|discord'); + process.exit(1); + } + + if (!chatId) { + console.error('Error: --chat is required (no default available)'); + process.exit(1); + } + + if (!messageId) { + console.error('Error: --message is required (no default available)'); + console.error('Provide --message or reply to a message first.'); + process.exit(1); + } + + try { + await addReaction(channel, chatId, messageId, emoji); + console.log(`✓ Reacted in ${channel}:${chatId} (${messageId}) with ${emoji}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} + +function showHelp(): void { + console.log(` +lettabot-react - Add reactions to messages + +Commands: + add [options] Add a reaction + +Add options: + --emoji, -e Emoji to react with (unicode or :alias:) + --channel, -c Channel: telegram, slack, discord (default: last used) + --chat, --to Chat/conversation ID (default: last messaged) + --message, -m Message ID (default: last messaged) + +Examples: + lettabot-react add --emoji "👀" + lettabot-react add --emoji :eyes: --channel discord --chat 123 --message 456 + +Environment variables: + TELEGRAM_BOT_TOKEN Required for Telegram + SLACK_BOT_TOKEN Required for Slack + DISCORD_BOT_TOKEN Required for Discord +`); +} + +const args = process.argv.slice(2); +const command = args[0]; + +switch (command) { + case 'add': + addCommand(args.slice(1)); + break; + case 'help': + case '--help': + case '-h': + showHelp(); + break; + default: + if (command) { + if (command.startsWith('-')) { + addCommand(args); + break; + } + console.error(`Unknown command: ${command}`); + } + showHelp(); + break; +} diff --git a/src/core/bot.ts b/src/core/bot.ts index f3d41fb..04d51f6 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -156,6 +156,7 @@ export class LettaBot { this.store.lastMessageTarget = { channel: msg.channel, chatId: msg.chatId, + messageId: msg.messageId, updatedAt: new Date().toISOString(), }; diff --git a/src/core/formatter.ts b/src/core/formatter.ts index 533e53e..8efd2bb 100644 --- a/src/core/formatter.ts +++ b/src/core/formatter.ts @@ -143,14 +143,14 @@ function formatTimestamp(date: Date, options: EnvelopeOptions): string { /** * Format a message with metadata envelope * - * Format: [Channel:ChatId Sender Timestamp] Message + * Format: [Channel:ChatId msg:MessageId Sender Timestamp] Message * * The Channel:ChatId format allows the agent to reply using: * lettabot-message send --text "..." --channel telegram --chat 123456789 * * Examples: - * - [telegram:123456789 Sarah Wednesday, Jan 28, 4:30 PM PST] Hello! - * - [slack:C1234567 @cameron Monday, Jan 27, 4:30 PM PST] Hello! + * - [telegram:123456789 msg:123 Sarah Wednesday, Jan 28, 4:30 PM PST] Hello! + * - [slack:C1234567 msg:1737685.1234 @cameron Monday, Jan 27, 4:30 PM PST] Hello! */ export function formatMessageEnvelope( msg: InboundMessage, @@ -161,6 +161,10 @@ export function formatMessageEnvelope( // Channel:ChatId (for lettabot-message CLI) parts.push(`${msg.channel}:${msg.chatId}`); + + if (msg.messageId) { + parts.push(`msg:${msg.messageId}`); + } // Group name (if group chat and enabled) if (opts.includeGroup !== false && msg.isGroup && msg.groupName?.trim()) { diff --git a/src/core/prompts.ts b/src/core/prompts.ts index f24ceb3..21b4db4 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -151,5 +151,9 @@ You have access to the \`lettabot-message\` CLI for sending messages: • You can also specify channel and chat: lettabot-message send --text "Hi" --channel discord --chat 123456789012345678 +You can also use \`lettabot-react\` to add emoji reactions: + lettabot-react add --emoji :eyes: + lettabot-react add --emoji :eyes: --channel telegram --chat 123456789 --message 987654321 + The system will tell you if you're in "silent mode" where the CLI is required. `.trim(); diff --git a/src/core/system-prompt.ts b/src/core/system-prompt.ts index 375b845..47bb085 100644 --- a/src/core/system-prompt.ts +++ b/src/core/system-prompt.ts @@ -16,6 +16,7 @@ You communicate through multiple channels and trigger types. Understanding when - When a user sends you a message, you are in responsive mode - Your text responses are automatically delivered to the user - You can also use \`lettabot-message\` CLI to send to OTHER channels +- You can use \`lettabot-react\` CLI to add emoji reactions **SILENT MODE** (Heartbeats, Cron Jobs, Polling, Background Tasks) - When triggered by scheduled tasks (heartbeats, cron) or background processes (email polling), you are in SILENT MODE @@ -28,6 +29,12 @@ lettabot-message send --text "Hello! I found something interesting." # Send to a specific channel and chat lettabot-message send --text "Hello!" --channel telegram --chat 123456789 + +# Add a reaction to the most recent message (uses last stored message ID) +lettabot-react add --emoji :eyes: + +# Add a reaction to a specific message +lettabot-react add --emoji :eyes: --channel telegram --chat 123456789 --message 987654321 \`\`\` The system will clearly indicate when you are in silent mode with a banner like: diff --git a/src/core/types.ts b/src/core/types.ts index bceeb2f..c3a10d3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -54,6 +54,7 @@ export interface InboundMessage { userId: string; userName?: string; // Display name (e.g., "Cameron") userHandle?: string; // Handle/username (e.g., "cameron" for @cameron) + messageId?: string; // Platform-specific message ID (for reactions, etc.) text: string; timestamp: Date; threadId?: string; // Slack thread_ts @@ -91,6 +92,7 @@ export interface BotConfig { export interface LastMessageTarget { channel: ChannelId; chatId: string; + messageId?: string; updatedAt: string; }