From d33a280d77642d0ac7d39d3089d2a38da5e7aff1 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 24 Feb 2026 12:05:51 -0800 Subject: [PATCH] feat: add reaction support for Signal channel (#353) --- src/channels/signal.ts | 37 ++++++++++++++++++++- src/cli/react.ts | 39 +++------------------- src/core/bot.ts | 8 +++-- src/core/emoji.ts | 69 +++++++++++++++++++++++++++++++++++++++ src/core/system-prompt.ts | 10 +++--- 5 files changed, 120 insertions(+), 43 deletions(-) create mode 100644 src/core/emoji.ts diff --git a/src/channels/signal.ts b/src/channels/signal.ts index cab53a4..197bfe7 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -316,6 +316,37 @@ This code expires in 1 hour.`; async editMessage(_chatId: string, _messageId: string, _text: string): Promise { // Signal doesn't support editing messages - no-op } + + async addReaction(chatId: string, messageId: string, emoji: string): Promise { + // messageId is encoded as "timestamp:author" by the inbound handler + const colonIdx = messageId.indexOf(':'); + if (colonIdx === -1) { + throw new Error(`Signal addReaction: invalid messageId format (expected "timestamp:author"): ${messageId}`); + } + const targetTimestamp = Number(messageId.slice(0, colonIdx)); + const targetAuthor = messageId.slice(colonIdx + 1); + if (!targetTimestamp || !targetAuthor) { + throw new Error(`Signal addReaction: could not parse timestamp/author from "${messageId}"`); + } + + const params: Record = { + emoji, + 'target-author': targetAuthor, + 'target-timestamp': targetTimestamp, + }; + + if (this.config.phoneNumber) { + params.account = this.config.phoneNumber; + } + + if (chatId.startsWith('group:')) { + params.groupId = chatId.slice('group:'.length); + } else { + params.recipient = [chatId === 'note-to-self' ? this.config.phoneNumber : chatId]; + } + + await this.rpcRequest('sendReaction', params); + } async sendTypingIndicator(chatId: string): Promise { try { @@ -792,12 +823,16 @@ This code expires in 1 hour.`; } } + // Signal uses timestamps as message IDs. Encode as "timestamp:author" so + // addReaction() can extract the target-author for sendReaction. + const signalTimestamp = envelope.timestamp || Date.now(); const msg: InboundMessage = { channel: 'signal', chatId, userId: source, + messageId: `${signalTimestamp}:${source}`, text: messageText || '', - timestamp: new Date(envelope.timestamp || Date.now()), + timestamp: new Date(signalTimestamp), isGroup, groupName: groupInfo?.groupName, wasMentioned, diff --git a/src/cli/react.ts b/src/cli/react.ts index d25b4f3..046f8a8 100644 --- a/src/cli/react.ts +++ b/src/cli/react.ts @@ -14,37 +14,7 @@ import { loadAppConfigOrExit, applyConfigToEnv } from '../config/index.js'; const config = loadAppConfigOrExit(); applyConfigToEnv(config); import { loadLastTarget } from './shared.js'; - -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) }; -} +import { resolveEmoji } from '../core/emoji.js'; async function addTelegramReaction(chatId: string, messageId: string, emoji: string): Promise { const token = process.env.TELEGRAM_BOT_TOKEN; @@ -111,21 +81,20 @@ async function addDiscordReaction(chatId: string, messageId: string, emoji: stri } async function addReaction(channel: string, chatId: string, messageId: string, emoji: string): Promise { - const { unicode, slackName } = resolveEmoji(emoji); + const { unicode, alias } = 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, '') || ''; + // Slack needs the text name (without colons), not Unicode + const name = alias || 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: diff --git a/src/core/bot.ts b/src/core/bot.ts index 39f8801..950f217 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -19,6 +19,7 @@ import type { GroupBatcher } from './group-batcher.js'; import { loadMemoryBlocks } from './memory.js'; import { SYSTEM_PROMPT } from './system-prompt.js'; import { parseDirectives, stripActionsBlock, type Directive } from './directives.js'; +import { resolveEmoji } from './emoji.js'; import { createManageTodoTool } from '../tools/todo.js'; import { syncTodosFromTool } from '../todo/store.js'; @@ -699,10 +700,13 @@ export class LettaBot implements AgentSession { continue; } if (targetId) { + // Resolve text aliases (thumbsup, eyes, etc.) to Unicode characters. + // The LLM typically outputs names; channel APIs need actual emoji. + const resolved = resolveEmoji(directive.emoji); try { - await adapter.addReaction(chatId, targetId, directive.emoji); + await adapter.addReaction(chatId, targetId, resolved.unicode); acted = true; - log.info(`Directive: reacted with ${directive.emoji}`); + log.info(`Directive: reacted with ${resolved.unicode} (${directive.emoji})`); } catch (err) { log.warn('Directive react failed:', err instanceof Error ? err.message : err); } diff --git a/src/core/emoji.ts b/src/core/emoji.ts new file mode 100644 index 0000000..78bb8e6 --- /dev/null +++ b/src/core/emoji.ts @@ -0,0 +1,69 @@ +/** + * Emoji alias resolution. + * + * Maps common text names (used by the LLM in directives) to their + * Unicode emoji characters. Shared between the directive executor and the + * lettabot-react CLI. + */ + +export const EMOJI_ALIAS_TO_UNICODE: Record = { + eyes: '👀', + thumbsup: '👍', + thumbs_up: '👍', + '+1': '👍', + thumbsdown: '👎', + thumbs_down: '👎', + '-1': '👎', + heart: '❤️', + fire: '🔥', + smile: '😄', + laughing: '😆', + tada: '🎉', + clap: '👏', + ok_hand: '👌', + wave: '👋', + thinking: '🤔', + pray: '🙏', + rocket: '🚀', + 100: '💯', + check: '✅', + x: '❌', + warning: '⚠️', + star: '⭐', + sparkles: '✨', + bulb: '💡', + memo: '📝', +}; + +const UNICODE_TO_ALIAS = new Map( + Object.entries(EMOJI_ALIAS_TO_UNICODE).map(([name, value]) => [value, name]), +); + +/** + * Strip optional colon wrappers: `:eyes:` → `eyes` + */ +function stripColons(input: string): string { + const match = input.match(/^:([^:]+):$/); + return match ? match[1] : input; +} + +/** + * Resolve an emoji string that may be a text alias, :alias:, or already Unicode. + * + * Returns `{ unicode, alias }` where: + * - `unicode` is the resolved emoji character (or the original input if already Unicode) + * - `alias` is the Slack-style name (without colons), or undefined if unknown + */ +export function resolveEmoji(input: string): { unicode: string; alias?: string } { + const name = stripColons(input.trim()); + + // Known alias → Unicode + const fromAlias = EMOJI_ALIAS_TO_UNICODE[name]; + if (fromAlias) { + return { unicode: fromAlias, alias: name }; + } + + // Already Unicode → look up alias + const knownAlias = UNICODE_TO_ALIAS.get(input); + return { unicode: input, alias: knownAlias }; +} diff --git a/src/core/system-prompt.ts b/src/core/system-prompt.ts index 2347b29..8878b75 100644 --- a/src/core/system-prompt.ts +++ b/src/core/system-prompt.ts @@ -46,7 +46,7 @@ lettabot-react add --emoji :eyes: lettabot-react add --emoji :eyes: --channel telegram --chat 123456789 --message 987654321 # Note: File sending supported on telegram, slack, discord, whatsapp (via API) -# Signal does not support files or reactions +# Signal supports reactions (via directives) but not file sending # Discover channel IDs (Discord and Slack) lettabot-channels list @@ -92,7 +92,7 @@ You can include an \`\` block at the **start** of your response to perf \`\`\` - + Great idea! \`\`\` @@ -101,8 +101,8 @@ This sends "Great idea!" and reacts with thumbsup. ### Available directives -- \`\` -- react to the message you are responding to. Emoji names (eyes, thumbsup, heart, fire, tada, clap) or unicode. -- \`\` -- react to a specific message by ID. +- \`\` -- react to the message you are responding to. Use the actual emoji character (👀, 👍, ❤️, 🔥, 🎉, 👏). +- \`\` -- react to a specific message by ID. - \`\` -- send a file or image to the same channel/chat. File paths are restricted to the configured send-file directory (default: \`data/outbound/\` in the working directory). Paths outside this directory are blocked. ### Actions-only response @@ -111,7 +111,7 @@ An \`\` block with no text after it executes silently (nothing sent to \`\`\` - + \`\`\`