feat: add reaction support for Signal channel (#353)

This commit is contained in:
Cameron
2026-02-24 12:05:51 -08:00
committed by GitHub
parent cb3a49b42b
commit d33a280d77
5 changed files with 120 additions and 43 deletions

View File

@@ -316,6 +316,37 @@ This code expires in 1 hour.`;
async editMessage(_chatId: string, _messageId: string, _text: string): Promise<void> {
// Signal doesn't support editing messages - no-op
}
async addReaction(chatId: string, messageId: string, emoji: string): Promise<void> {
// 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<string, unknown> = {
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<void> {
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,

View File

@@ -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<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) };
}
import { resolveEmoji } from '../core/emoji.js';
async function addTelegramReaction(chatId: string, messageId: string, emoji: string): Promise<void> {
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<void> {
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:

View File

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

69
src/core/emoji.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Emoji alias resolution.
*
* Maps common text names (used by the LLM in <react> directives) to their
* Unicode emoji characters. Shared between the directive executor and the
* lettabot-react CLI.
*/
export const EMOJI_ALIAS_TO_UNICODE: Record<string, string> = {
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<string, string>(
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 };
}

View File

@@ -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 \`<actions>\` block at the **start** of your response to perf
\`\`\`
<actions>
<react emoji="thumbsup" />
<react emoji="👍" />
</actions>
Great idea!
\`\`\`
@@ -101,8 +101,8 @@ This sends "Great idea!" and reacts with thumbsup.
### Available directives
- \`<react emoji="eyes" />\` -- react to the message you are responding to. Emoji names (eyes, thumbsup, heart, fire, tada, clap) or unicode.
- \`<react emoji="fire" message="123" />\` -- react to a specific message by ID.
- \`<react emoji="👀" />\` -- react to the message you are responding to. Use the actual emoji character (👀, 👍, ❤️, 🔥, 🎉, 👏).
- \`<react emoji="🔥" message="123" />\` -- react to a specific message by ID.
- \`<send-file path="/path/to/file.png" kind="image" caption="..." />\` -- 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 \`<actions>\` block with no text after it executes silently (nothing sent to
\`\`\`
<actions>
<react emoji="eyes" />
<react emoji="👀" />
</actions>
\`\`\`