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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
274
src/cli/react.ts
Normal 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;
|
||||
}
|
||||
@@ -156,6 +156,7 @@ export class LettaBot {
|
||||
this.store.lastMessageTarget = {
|
||||
channel: msg.channel,
|
||||
chatId: msg.chatId,
|
||||
messageId: msg.messageId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user