feat: add reaction support for Signal channel (#353)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
69
src/core/emoji.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
\`\`\`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user