diff --git a/.gitignore b/.gitignore index fcba737..710df9f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ data/whatsapp-session/ lettabot.yaml lettabot.yml bun.lock +.tool-versions diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 2704615..7fbd1ab 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -6,7 +6,7 @@ */ import type { ChannelAdapter } from './types.js'; -import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js'; +import type { InboundAttachment, InboundMessage, InboundReaction, OutboundFile, OutboundMessage } from '../core/types.js'; import type { DmPolicy } from '../pairing/types.js'; import { isUserAllowed, upsertPairingRequest } from '../pairing/store.js'; import { buildAttachmentPath, downloadToFile } from './attachments.js'; @@ -123,10 +123,12 @@ Ask the bot owner to approve with: intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, + GatewayIntentBits.DirectMessageReactions, ], - partials: [Partials.Channel], + partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User], }); this.client.on('ready', () => { @@ -245,6 +247,14 @@ Ask the bot owner to approve with: console.error('[Discord] Client error:', err); }); + this.client.on('messageReactionAdd', async (reaction, user) => { + await this.handleReactionEvent(reaction, user, 'added'); + }); + + this.client.on('messageReactionRemove', async (reaction, user) => { + await this.handleReactionEvent(reaction, user, 'removed'); + }); + console.log('[Discord] Connecting...'); await this.client.login(this.config.token); } @@ -301,6 +311,69 @@ Ask the bot owner to approve with: return true; } + private async handleReactionEvent( + reaction: import('discord.js').MessageReaction | import('discord.js').PartialMessageReaction, + user: import('discord.js').User | import('discord.js').PartialUser, + action: InboundReaction['action'] + ): Promise { + if ('bot' in user && user.bot) return; + + try { + if (reaction.partial) { + await reaction.fetch(); + } + if (reaction.message.partial) { + await reaction.message.fetch(); + } + } catch (err) { + console.warn('[Discord] Failed to fetch reaction/message:', err); + } + + const message = reaction.message; + const channelId = message.channel?.id; + if (!channelId) return; + + const access = await this.checkAccess(user.id); + if (access !== 'allowed') { + return; + } + + const emoji = reaction.emoji.id + ? reaction.emoji.toString() + : (reaction.emoji.name || reaction.emoji.toString()); + if (!emoji) return; + + const isGroup = !!message.guildId; + const groupName = isGroup && 'name' in message.channel + ? message.channel.name || undefined + : undefined; + const userId = user.id; + const userName = 'username' in user ? (user.username ?? undefined) : undefined; + const displayName = message.guild?.members.cache.get(userId)?.displayName + || userName + || userId; + + this.onMessage?.({ + channel: 'discord', + chatId: channelId, + userId: userId, + userName: displayName, + userHandle: userName || userId, + messageId: message.id, + text: '', + timestamp: new Date(), + isGroup, + groupName, + reaction: { + emoji, + messageId: message.id, + action, + }, + }).catch((err) => { + console.error('[Discord] Error handling reaction:', err); + }); + } + private async collectAttachments(attachments: unknown, channelId: string): Promise { if (!attachments || typeof attachments !== 'object') return []; const list = Array.from((attachments as { values: () => Iterable }).values?.() || []); diff --git a/src/channels/slack.ts b/src/channels/slack.ts index b10d22c..4e90513 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -5,7 +5,7 @@ */ import type { ChannelAdapter } from './types.js'; -import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js'; +import type { InboundAttachment, InboundMessage, InboundReaction, OutboundFile, OutboundMessage } from '../core/types.js'; import { createReadStream } from 'node:fs'; import { basename } from 'node:path'; import { buildAttachmentPath, downloadToFile } from './attachments.js'; @@ -161,6 +161,14 @@ export class SlackAdapter implements ChannelAdapter { }); } }); + + this.app.event('reaction_added', async ({ event }) => { + await this.handleReactionEvent(event as SlackReactionEvent, 'added'); + }); + + this.app.event('reaction_removed', async ({ event }) => { + await this.handleReactionEvent(event as SlackReactionEvent, 'removed'); + }); console.log('[Slack] Connecting via Socket Mode...'); await this.app.start(); @@ -239,6 +247,48 @@ export class SlackAdapter implements ChannelAdapter { // This is a no-op } + private async handleReactionEvent( + event: SlackReactionEvent, + action: InboundReaction['action'] + ): Promise { + const userId = event.user || ''; + if (!userId) return; + + if (this.config.allowedUsers && this.config.allowedUsers.length > 0) { + if (!this.config.allowedUsers.includes(userId)) { + return; + } + } + + const channelId = event.item?.channel; + const messageId = event.item?.ts; + if (!channelId || !messageId) return; + + const emoji = event.reaction ? `:${event.reaction}:` : ''; + if (!emoji) return; + + const isGroup = !channelId.startsWith('D'); + const eventTs = Number(event.event_ts); + const timestamp = Number.isFinite(eventTs) ? new Date(eventTs * 1000) : new Date(); + + await this.onMessage?.({ + channel: 'slack', + chatId: channelId, + userId, + userHandle: userId, + messageId, + text: '', + timestamp, + isGroup, + groupName: isGroup ? channelId : undefined, + reaction: { + emoji, + messageId, + action, + }, + }); + } + private async collectAttachments( files: SlackFile[] | undefined, channelId: string @@ -262,6 +312,16 @@ type SlackFile = { url_private_download?: string; }; +type SlackReactionEvent = { + user?: string; + reaction?: string; + item?: { + channel?: string; + ts?: string; + }; + event_ts?: string; +}; + async function maybeDownloadSlackFile( attachmentsDir: string | undefined, attachmentsMaxBytes: number | undefined, diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 64360ae..2c97fcc 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -7,7 +7,7 @@ import { Bot, InputFile } from 'grammy'; import type { ChannelAdapter } from './types.js'; -import type { InboundAttachment, InboundMessage, OutboundFile, OutboundMessage } from '../core/types.js'; +import type { InboundAttachment, InboundMessage, InboundReaction, OutboundFile, OutboundMessage } from '../core/types.js'; import type { DmPolicy } from '../pairing/types.js'; import { isUserAllowed, @@ -175,6 +175,51 @@ export class TelegramAdapter implements ChannelAdapter { } }); + // Handle message reactions (Bot API >= 7.0) + this.bot.on('message_reaction', async (ctx) => { + const reaction = ctx.update.message_reaction; + if (!reaction) return; + const userId = reaction.user?.id; + if (!userId) return; + + const access = await this.checkAccess( + String(userId), + reaction.user?.username, + reaction.user?.first_name + ); + if (access !== 'allowed') { + return; + } + + const chatId = reaction.chat?.id; + const messageId = reaction.message_id; + if (!chatId || !messageId) return; + + const newEmoji = extractTelegramReaction(reaction.new_reaction?.[0]); + const oldEmoji = extractTelegramReaction(reaction.old_reaction?.[0]); + const emoji = newEmoji || oldEmoji; + if (!emoji) return; + + const action: InboundReaction['action'] = newEmoji ? 'added' : 'removed'; + + if (this.onMessage) { + await this.onMessage({ + channel: 'telegram', + chatId: String(chatId), + userId: String(userId), + userName: reaction.user?.username || reaction.user?.first_name || undefined, + messageId: String(messageId), + text: '', + timestamp: new Date(), + reaction: { + emoji, + messageId: String(messageId), + action, + }, + }); + } + }); + // Handle voice messages (must be registered before generic 'message' handler) this.bot.on('message:voice', async (ctx) => { const userId = ctx.from?.id; @@ -481,6 +526,21 @@ export class TelegramAdapter implements ChannelAdapter { } } +function extractTelegramReaction(reaction?: { + type?: string; + emoji?: string; + custom_emoji_id?: string; +}): string | null { + if (!reaction) return null; + if ('emoji' in reaction && reaction.emoji) { + return reaction.emoji; + } + if ('custom_emoji_id' in reaction && reaction.custom_emoji_id) { + return `custom:${reaction.custom_emoji_id}`; + } + return null; +} + const TELEGRAM_EMOJI_ALIAS_TO_UNICODE: Record = { eyes: '👀', thumbsup: '👍', diff --git a/src/core/formatter.test.ts b/src/core/formatter.test.ts index a269500..4f77f1e 100644 --- a/src/core/formatter.test.ts +++ b/src/core/formatter.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { formatMessageEnvelope } from './formatter.js'; import type { InboundMessage } from './types.js'; - // Helper to create base message function createMessage(overrides: Partial = {}): InboundMessage { return { @@ -216,4 +215,25 @@ describe('formatMessageEnvelope', () => { expect(result).not.toMatch(/Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday/); }); }); + + describe('reactions', () => { + it('includes reaction metadata when present', () => { + const msg = createMessage({ + messageId: '1710000000.000000', + reaction: { + emoji: ':thumbsup:', + messageId: '1710000000.000000', + action: 'added', + }, + }); + + const result = formatMessageEnvelope(msg); + expect(result).toContain('Reaction: added :thumbsup: (msg:1710000000.000000)'); + }); + + it('omits reaction metadata when not present', () => { + const result = formatMessageEnvelope(createMessage()); + expect(result).not.toContain('Reaction:'); + }); + }); }); diff --git a/src/core/formatter.ts b/src/core/formatter.ts index 8c01c65..76e4459 100644 --- a/src/core/formatter.ts +++ b/src/core/formatter.ts @@ -169,6 +169,14 @@ function formatAttachments(msg: InboundMessage): string { return `Attachments:\n${lines.join('\n')}`; } +function formatReaction(msg: InboundMessage): string { + if (!msg.reaction) return ''; + const action = msg.reaction.action || 'added'; + const emoji = msg.reaction.emoji; + const target = msg.reaction.messageId; + return `Reaction: ${action} ${emoji} (msg:${target})`; +} + /** * Format a message with metadata envelope * @@ -238,7 +246,8 @@ export function formatMessageEnvelope( const hint = formatHint ? `\n(Format: ${formatHint})` : ''; const attachmentBlock = formatAttachments(msg); - const bodyParts = [msg.text, attachmentBlock].filter((part) => part && part.trim()); + const reactionBlock = formatReaction(msg); + const bodyParts = [msg.text, reactionBlock, attachmentBlock].filter((part) => part && part.trim()); const body = bodyParts.join('\n'); const spacer = body ? ` ${body}` : ''; return `${envelope}${spacer}${hint}`; diff --git a/src/core/types.ts b/src/core/types.ts index 09a3349..962218b 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -55,6 +55,12 @@ export interface InboundAttachment { kind?: 'image' | 'file' | 'audio' | 'video'; } +export interface InboundReaction { + emoji: string; + messageId: string; + action?: 'added' | 'removed'; +} + /** * Inbound message from any channel */ @@ -73,6 +79,7 @@ export interface InboundMessage { wasMentioned?: boolean; // Was bot explicitly mentioned? (groups only) replyToUser?: string; // Phone number of who they're replying to (if reply) attachments?: InboundAttachment[]; + reaction?: InboundReaction; } /**