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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,3 +49,4 @@ data/whatsapp-session/
|
||||
lettabot.yaml
|
||||
lettabot.yml
|
||||
bun.lock
|
||||
.tool-versions
|
||||
|
||||
@@ -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?.() || []);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '👍',
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user