Add reactions CLI for Telegram/Slack/Discord (#20)

* Add lettabot-react CLI and message IDs

* Expose message IDs for reactions

---------

Co-authored-by: Jason Carreira <jason@visotrust.com>
This commit is contained in:
Jason Carreira
2026-01-29 18:40:50 -05:00
committed by GitHub
parent ccc13f4242
commit d420e5d3b5
11 changed files with 302 additions and 4 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(),
});

View File

@@ -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,

274
src/cli/react.ts Normal file
View File

@@ -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<string, string> = {
eyes: '👀',
thumbsup: '👍',
thumbs_up: '👍',
'+1': '👍',
heart: '❤️',
fire: '🔥',
smile: '😄',
laughing: '😆',
tada: '🎉',
clap: '👏',
ok_hand: '👌',
};
const UNICODE_TO_ALIAS = new Map<string, string>(
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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> Emoji to react with (unicode or :alias:)
--channel, -c <name> Channel: telegram, slack, discord (default: last used)
--chat, --to <id> Chat/conversation ID (default: last messaged)
--message, -m <id> 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;
}

View File

@@ -156,6 +156,7 @@ export class LettaBot {
this.store.lastMessageTarget = {
channel: msg.channel,
chatId: msg.chatId,
messageId: msg.messageId,
updatedAt: new Date().toISOString(),
};

View File

@@ -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()) {

View File

@@ -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();

View File

@@ -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:

View File

@@ -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;
}