feat: add inbound reaction handling (#134)

* Add inbound reaction handling

* Add inbound reaction types

* Add reaction envelope test

---------

Co-authored-by: Jason Carreira <jason@visotrust.com>
This commit is contained in:
Cameron
2026-02-04 12:08:49 -08:00
committed by GitHub
parent 0c79b147f4
commit 550ea6cf2e
7 changed files with 236 additions and 6 deletions

1
.gitignore vendored
View File

@@ -49,3 +49,4 @@ data/whatsapp-session/
lettabot.yaml
lettabot.yml
bun.lock
.tool-versions

View File

@@ -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<void> {
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<InboundAttachment[]> {
if (!attachments || typeof attachments !== 'object') return [];
const list = Array.from((attachments as { values: () => Iterable<DiscordAttachment> }).values?.() || []);

View File

@@ -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<void> {
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,

View File

@@ -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<string, string> = {
eyes: '👀',
thumbsup: '👍',

View File

@@ -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> = {}): 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:');
});
});
});

View File

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

View File

@@ -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;
}
/**