diff --git a/apps/api/src/handlers/call-roomote-via-emoji.test.ts b/apps/api/src/handlers/call-roomote-via-emoji.test.ts new file mode 100644 index 000000000..4ceddf1bf --- /dev/null +++ b/apps/api/src/handlers/call-roomote-via-emoji.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getAutomationRuntime = vi.hoisted(() => vi.fn()); + +vi.mock('@roomote/db/server', () => ({ + getAutomationRuntime, +})); + +import { + CALL_ROOMOTE_VIA_EMOJI_PROMPT, + getCallRoomoteViaEmojiConfiguration, +} from './call-roomote-via-emoji'; + +describe('Call Roomote via emoji configuration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses the exact default prompt when no instructions are configured', async () => { + getAutomationRuntime.mockResolvedValue({ + enabled: true, + instructions: null, + settings: { emoji: ':white_check_mark:' }, + }); + + await expect(getCallRoomoteViaEmojiConfiguration('✅')).resolves.toEqual({ + emoji: ':white_check_mark:', + prompt: 'Act on this', + }); + expect(CALL_ROOMOTE_VIA_EMOJI_PROMPT).toBe('Act on this'); + }); + + it('appends configured instructions after the default prompt', async () => { + getAutomationRuntime.mockResolvedValue({ + enabled: true, + instructions: 'Prioritize safety.', + settings: { emoji: 'white_check_mark' }, + }); + + await expect( + getCallRoomoteViaEmojiConfiguration('white_check_mark'), + ).resolves.toMatchObject({ + prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', + }); + }); + + it('ignores disabled and non-matching reactions', async () => { + getAutomationRuntime.mockResolvedValue({ + enabled: false, + instructions: null, + settings: { emoji: 'eyes' }, + }); + await expect( + getCallRoomoteViaEmojiConfiguration('eyes'), + ).resolves.toBeNull(); + + getAutomationRuntime.mockResolvedValue({ + enabled: true, + instructions: null, + settings: { emoji: 'eyes' }, + }); + await expect( + getCallRoomoteViaEmojiConfiguration('fire'), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/api/src/handlers/call-roomote-via-emoji.ts b/apps/api/src/handlers/call-roomote-via-emoji.ts new file mode 100644 index 000000000..1df9ec0ec --- /dev/null +++ b/apps/api/src/handlers/call-roomote-via-emoji.ts @@ -0,0 +1,36 @@ +import { reactionEmojiMatches } from '@roomote/communication/reaction-emoji'; +import { getAutomationRuntime } from '@roomote/db/server'; + +export const CALL_ROOMOTE_VIA_EMOJI_PROMPT = 'Act on this'; + +type CallRoomoteViaEmojiConfiguration = { + emoji: string; + prompt: string; +}; + +export async function getCallRoomoteViaEmojiConfiguration( + receivedEmoji: string, +): Promise { + const automation = await getAutomationRuntime('call_roomote_via_emoji'); + const emoji = + typeof automation.settings.emoji === 'string' + ? automation.settings.emoji.trim() + : ''; + + if ( + !automation.enabled || + !emoji || + !reactionEmojiMatches(emoji, receivedEmoji) + ) { + return null; + } + + const instructions = automation.instructions?.trim(); + + return { + emoji, + prompt: instructions + ? `${CALL_ROOMOTE_VIA_EMOJI_PROMPT}\n\nAdditional instructions:\n${instructions}` + : CALL_ROOMOTE_VIA_EMOJI_PROMPT, + }; +} diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index eeda20be2..f000d1984 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -53,6 +53,7 @@ const mocks = vi.hoisted(() => ({ fetchThreadHistory: vi.fn(), shouldRouteUnmentioned: vi.fn(), enqueueGatewayEvent: vi.fn(), + callViaEmojiConfig: vi.fn(), })); vi.mock('@roomote/redis', async (importOriginal) => { @@ -140,6 +141,10 @@ vi.mock('../unmentioned-thread-reply.js', () => ({ shouldRouteUnmentionedDiscordThreadReplyToAgent: mocks.shouldRouteUnmentioned, })); +vi.mock('../../call-roomote-via-emoji.js', () => ({ + getCallRoomoteViaEmojiConfiguration: mocks.callViaEmojiConfig, +})); + vi.mock('../task-orchestration.js', () => ({ startNewDiscordTask: mocks.startNewTask, })); @@ -298,6 +303,7 @@ describe('Discord Gateway event handler', () => { mocks.shouldRouteUnmentioned.mockResolvedValue(true); mocks.queueMessage.mockResolvedValue(true); mocks.enqueueGatewayEvent.mockResolvedValue({ jobId: 'event-message-1' }); + mocks.callViaEmojiConfig.mockResolvedValue(null); }); afterEach(() => { @@ -357,6 +363,59 @@ describe('Discord Gateway event handler', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); + it('turns a configured reaction into a thread task entry', async () => { + mocks.callViaEmojiConfig.mockResolvedValue({ + emoji: 'white_check_mark', + prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', + }); + mocks.getChannel.mockResolvedValue({ + id: 'channel-1', + name: 'general', + type: 0, + guildId: 'guild-1', + }); + + const response = await postEvent({ + eventId: 'channel-1:message-1:discord-user-1:white_check_mark', + eventType: 'MESSAGE_REACTION_ADD', + receivedAt: '2026-07-12T15:00:00.000Z', + payload: { + user_id: 'discord-user-1', + channel_id: 'channel-1', + message_id: 'message-1', + guild_id: 'guild-1', + emoji: { id: null, name: 'white_check_mark' }, + member: { + user: { id: 'discord-user-1', username: 'matt' }, + }, + }, + }); + + expect(response.status).toBe(200); + expect(mocks.channelAutoStart).not.toHaveBeenCalled(); + expect(mocks.addReaction).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'message-1', + name: '👀', + }); + expect(mocks.startNewTask).toHaveBeenCalledWith( + expect.objectContaining({ + requesterDiscordUserId: 'discord-user-1', + launchOwnerUserId: 'roomote-user-1', + queuedMessage: expect.objectContaining({ + text: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', + }), + metadata: expect.objectContaining({ + communicationMessageId: 'message-1', + communicationAnchorMessageId: 'message-1', + }), + replyToMessageId: 'message-1', + replyToChannelId: 'channel-1', + contextThroughMessageId: 'message-1', + }), + ); + }); + it('rejects an invalid Gateway secret before claiming the event', async () => { const response = await postEvent(envelope(message()), 'wrong-secret'); @@ -1976,6 +2035,80 @@ describe('Discord Gateway event handler', () => { ); }); + it('preserves a pending reaction target after account linking', async () => { + const eventId = + 'channel-1:message-target:discord-user-1:white_check_mark:42'; + const originalEvent = { + eventId, + eventType: 'MESSAGE_CREATE' as const, + receivedAt: '2026-07-12T15:00:00.000Z', + reactionTarget: { + channelId: 'channel-1', + messageId: 'message-target', + }, + payload: { + id: eventId, + channel_id: 'channel-1', + guild_id: 'guild-1', + content: '<@bot-1> Act on this', + author: { id: 'discord-user-1', username: 'matt' }, + mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], + attachments: [], + message_reference: { + message_id: 'message-target', + channel_id: 'channel-1', + }, + }, + }; + mocks.consumeLinkCode.mockResolvedValue('roomote-user-1'); + mocks.redisGetdel.mockResolvedValue(JSON.stringify(originalEvent)); + mocks.getChannel.mockImplementation(async (channelId: string) => + channelId === 'dm-1' + ? { id: 'dm-1', name: 'Direct message', type: 1 } + : { + id: 'channel-1', + guildId: 'guild-1', + name: 'general', + type: 0, + }, + ); + const interaction = { + id: 'interaction-link', + application_id: 'app-1', + type: 2, + token: 'interaction-token', + channel_id: 'dm-1', + user: { id: 'discord-user-1', username: 'matt' }, + data: { + name: 'link', + type: 1, + options: [{ name: 'code', type: 3, value: 'link-abcdefghijklmnop' }], + }, + }; + + const response = await postEvent( + envelope(interaction, 'INTERACTION_CREATE'), + ); + + expect(response.status).toBe(200); + expect(mocks.startNewTask).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + communicationMessageId: 'message-target', + communicationAnchorMessageId: 'message-target', + }), + replyToMessageId: 'message-target', + replyToChannelId: 'channel-1', + contextThroughMessageId: 'message-target', + }), + ); + expect(mocks.addReaction).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'message-target', + name: '👀', + }); + }); + it('requires /link in a DM without consuming the one-shot code', async () => { mocks.getChannel.mockResolvedValue({ id: 'channel-1', diff --git a/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts b/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts index db0106314..0aa73881e 100644 --- a/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts +++ b/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts @@ -312,6 +312,81 @@ describe('startNewDiscordTask', () => { expect(agentPrompt).not.toContain('@Roomote investigate the flaky build'); }); + it('excludes attachments posted after a reacted-to message', async () => { + const file = (id: string) => ({ + id, + name: `${id}.txt`, + mimeType: 'text/plain', + size: 12, + url: `https://cdn.discordapp.com/attachments/${id}.txt`, + }); + const provider = { + fetchChannelMessages: vi.fn().mockResolvedValue({ + messages: [ + { + id: '100', + user: 'u-alice', + username: 'Alice', + text: 'Earlier context', + files: [file('before')], + }, + { + id: '200', + user: 'u-alice', + username: 'Alice', + text: 'React to this', + files: [file('target')], + }, + { + id: '300', + user: 'u-bob', + username: 'Bob', + text: 'Later context', + files: [file('after')], + }, + ], + }), + }; + + await startNewDiscordTask({ + provider: provider as never, + applicationId: 'application-1', + requesterDiscordUserId: 'discord-user-1', + launchOwnerUserId: 'user-1', + contextThroughMessageId: '200', + queuedMessage: { + provider: 'discord', + text: 'Act on this', + user: 'Matt', + userId: 'user-1', + ts: 'channel-1:200:discord-user-1:white_check_mark:42', + }, + metadata: { + communicationProvider: 'discord', + communicationChannelId: 'channel-1', + communicationThreadId: 'thread-1', + communicationMessageId: '200', + }, + channel: { + channelId: 'thread-1', + channelName: 'Task thread', + channelType: 11, + guildId: 'guild-1', + parentChannelId: 'channel-1', + isDirectMessage: false, + isThread: true, + }, + }); + + expect(mocks.processAttachments).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'before' }), + expect.objectContaining({ id: 'target' }), + ]); + expect(mocks.processAttachments).not.toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'after' })]), + ); + }); + it('does not inherit prior thread context for /new (forceNewThread)', async () => { const provider = { fetchChannelMessages: vi.fn().mockResolvedValue({ diff --git a/apps/api/src/handlers/discord/__tests__/thread-context.test.ts b/apps/api/src/handlers/discord/__tests__/thread-context.test.ts index 3a290f46d..1cd5f921b 100644 --- a/apps/api/src/handlers/discord/__tests__/thread-context.test.ts +++ b/apps/api/src/handlers/discord/__tests__/thread-context.test.ts @@ -296,6 +296,55 @@ describe('buildDiscordContinuationPrompt', () => { expect(result.claimedMessageIds).toEqual(['100']); }); + it('orders synthetic reaction turns by their real target message', async () => { + const provider = { + fetchChannelMessages: vi.fn().mockResolvedValue({ + messages: [ + { + id: '100', + user: 'u-alice', + username: 'Alice', + text: 'Please investigate this failure', + }, + { + id: '200', + user: 'u-bob', + username: 'Bob', + text: 'This happened later', + }, + ], + }), + fetchMessage: vi.fn().mockResolvedValue({ + provider: 'discord', + id: '100', + user: 'u-alice', + username: 'Alice', + text: 'Please investigate this failure', + channelId: 'channel-1', + fileCount: 0, + }), + }; + + const result = await buildDiscordContinuationPrompt({ + provider: provider as never, + channelId: 'channel-1', + replyToMessageId: '100', + contextThroughMessageId: '100', + queuedMessage: { + provider: 'discord', + text: 'Act on this', + user: 'Matt', + ts: 'channel-1:100:u-matt:white_check_mark', + }, + }); + + expect(result.message.formattedPrompt).toContain( + 'Alice: Please investigate this failure', + ); + expect(result.message.formattedPrompt).not.toContain('This happened later'); + expect(result.message.formattedPrompt).toContain('Act on this'); + }); + it('includes an explicit replied-to human message even when already delivered', async () => { deliveryMocks.claim.mockResolvedValue([]); const provider = { diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index 8730ed824..3160baf32 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -6,6 +6,7 @@ import { getDiscordInteractionCreate, getDiscordInteractionUser, getDiscordMessageCreate, + getDiscordReactionAdd, isDiscordBotMentioned, isDiscordTaskEntryEvent, parseDiscordGatewayEvent, @@ -38,6 +39,7 @@ import { } from '@roomote/sdk/server'; import { apiLogger } from '../../logging.js'; +import { getCallRoomoteViaEmojiConfiguration } from '../call-roomote-via-emoji.js'; import { syncActingUserForInboundMessage } from '../tasks/acting-user-sync.js'; import { attachOutOfBandContextToCommunicationMessage, @@ -228,7 +230,86 @@ async function refreshDiscordUserMappingBestEffort(input: { } } -async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { +type DiscordReactionTarget = { channelId: string; messageId: string }; + +function getPersistedDiscordReactionTarget( + event: DiscordGatewayEvent, +): DiscordReactionTarget | undefined { + const value = event.reactionTarget; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const target = value as Record; + return typeof target.channelId === 'string' && + typeof target.messageId === 'string' + ? { channelId: target.channelId, messageId: target.messageId } + : undefined; +} + +async function processDiscordGatewayEvent( + event: DiscordGatewayEvent, + options: { + reactionTarget?: DiscordReactionTarget; + } = {}, +) { + const reactionTarget = + options.reactionTarget ?? getPersistedDiscordReactionTarget(event); + const reaction = getDiscordReactionAdd(event); + if (reaction) { + const resolved = await resolveDiscordProvider(); + if (reaction.user_id === resolved.botUserId || !reaction.emoji.name) { + return { ok: true, ignored: 'bot_or_missing_reaction' }; + } + + const configuration = await getCallRoomoteViaEmojiConfiguration( + reaction.emoji.name, + ); + if (!configuration) { + return { ok: true, ignored: 'reaction_not_configured' }; + } + + const author = reaction.member?.user ?? { + id: reaction.user_id, + username: `Discord user ${reaction.user_id}`, + }; + return processDiscordGatewayEvent( + { + eventId: event.eventId, + eventType: 'MESSAGE_CREATE', + receivedAt: event.receivedAt, + reactionTarget: { + channelId: reaction.channel_id, + messageId: reaction.message_id, + }, + payload: { + id: event.eventId, + channel_id: reaction.channel_id, + ...(reaction.guild_id ? { guild_id: reaction.guild_id } : {}), + content: `<@${resolved.botUserId}> ${configuration.prompt}`, + author, + mentions: [ + { + id: resolved.botUserId, + username: 'Roomote', + bot: true, + }, + ], + attachments: [], + message_reference: { + message_id: reaction.message_id, + channel_id: reaction.channel_id, + }, + }, + }, + { + reactionTarget: { + channelId: reaction.channel_id, + messageId: reaction.message_id, + }, + }, + ); + } + const interaction = getDiscordInteractionCreate(event); const message = getDiscordMessageCreate(event); if (interaction?.type === 3) { @@ -256,10 +337,14 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { }); const metadata = discordMetadataForChannel({ channel, - messageId: event.payload.id, + messageId: reactionTarget?.messageId ?? event.eventId, // Only a real channel message provides an anchor for the task thread; // interactions (slash commands, buttons) do not. - ...(message?.id ? { anchorMessageId: message.id } : {}), + ...(reactionTarget?.messageId + ? { anchorMessageId: reactionTarget.messageId } + : message?.id + ? { anchorMessageId: message.id } + : {}), }); if (interaction?.type === 3) { @@ -276,7 +361,7 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { // Auto-respond channels run first, mirroring Slack: a message in a // configured channel — mentioned or not, bot- or human-authored — is // consumed here and never reaches the mention/task-entry gating below. - if (message && !interaction) { + if (message && !interaction && !reactionTarget) { const handledAsChannelAutoStart = await maybeHandleDiscordChannelAutoStart({ event, message, @@ -497,7 +582,11 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { channel, discordUserId: sender.id, ...(interaction ? { interaction: interactionReplyContext(event) } : {}), - ...(message?.id ? { replyToMessageId: message.id } : {}), + ...(reactionTarget?.messageId + ? { replyToMessageId: reactionTarget.messageId } + : message?.id + ? { replyToMessageId: message.id } + : {}), }); return { ok: true, @@ -701,6 +790,9 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { : {}), botUserId: resolved.botUserId, queuedMessage, + ...(reactionTarget?.messageId + ? { contextThroughMessageId: reactionTarget.messageId } + : {}), ...(message?.message_reference?.message_id ? { replyToMessageId: message.message_reference.message_id, @@ -760,7 +852,11 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { await releaseDiscordContinuationClaim(continuationClaim); throw error; } - await setLatestInboundMessageId('discord', activeRun.id, queuedMessage.ts); + await setLatestInboundMessageId( + 'discord', + activeRun.id, + reactionTarget?.messageId ?? queuedMessage.ts, + ); // Match Slack: eyes is an intake-only platform ack. Active follow-ups are // already durable once queued; agents may still react when turn policy allows. return { ok: true, queued: true, runId: activeRun.id }; @@ -798,6 +894,9 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { : {}), botUserId: resolved.botUserId, queuedMessage, + ...(reactionTarget?.messageId + ? { contextThroughMessageId: reactionTarget.messageId } + : {}), ...(message?.message_reference?.message_id ? { replyToMessageId: message.message_reference.message_id, @@ -884,8 +983,8 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { if (message?.id) { try { await resolved.provider.addReaction({ - channelId: channel.channelId, - messageId: message.id, + channelId: reactionTarget?.channelId ?? channel.channelId, + messageId: reactionTarget?.messageId ?? message.id, name: '👀', }); intakeAckPinned = true; @@ -918,6 +1017,9 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { : {}), } : {}), + ...(reactionTarget?.messageId + ? { contextThroughMessageId: reactionTarget.messageId } + : {}), }); } catch (error) { if (isDeploymentReadOnlyError(error)) { diff --git a/apps/api/src/handlers/discord/task-orchestration.ts b/apps/api/src/handlers/discord/task-orchestration.ts index 1fa72856e..c1aff1bcd 100644 --- a/apps/api/src/handlers/discord/task-orchestration.ts +++ b/apps/api/src/handlers/discord/task-orchestration.ts @@ -37,6 +37,17 @@ import { type DiscordThreadHistoryMessage, } from './thread-context.js'; +function compareDiscordMessageIds(left: string, right: string): number { + try { + const leftId = BigInt(left); + const rightId = BigInt(right); + if (leftId === rightId) return 0; + return leftId < rightId ? -1 : 1; + } catch { + return left.localeCompare(right); + } +} + /** * Soft-clear the MESSAGE_CREATE intake 👀 when a path ends without a worker. * Platform answers and auto-start skips never hit onStart cleanup. @@ -99,6 +110,8 @@ export async function startNewDiscordTask(input: { replyToMessageId?: string; /** Discord `message_reference.channel_id` when present. */ replyToChannelId?: string; + /** Real Discord message included as the endpoint of synthetic reaction context. */ + contextThroughMessageId?: string; }) { const existingRun = await findCommunicationTaskRunBySourceEvent({ provider: 'discord', @@ -176,11 +189,18 @@ export async function startNewDiscordTask(input: { text: input.queuedMessage.text, attachments: [], }; - const historyWithTrigger = history.some( + const contextThroughMessageId = input.contextThroughMessageId; + const contextHistory = contextThroughMessageId + ? history.filter( + (message) => + compareDiscordMessageIds(message.id, contextThroughMessageId) <= 0, + ) + : history; + const historyWithTrigger = contextHistory.some( (message) => message.id === triggeringMessage.id, ) - ? history - : [...history, triggeringMessage]; + ? contextHistory + : [...contextHistory, triggeringMessage]; // Full thread launches get the reconstructed transcript; top-level channel // reply launches only pass the explicit reply target + current turn. const includeReplyContext = @@ -198,7 +218,7 @@ export async function startNewDiscordTask(input: { }, ]; const historyAttachments = includeReplyContext - ? toDiscordAttachmentsFromHistory(history, { + ? toDiscordAttachmentsFromHistory(contextHistory, { excludeMessageId: input.queuedMessage.ts, }) : []; @@ -220,7 +240,8 @@ export async function startNewDiscordTask(input: { const threadContext = includeReplyContext ? formatDiscordThreadContext({ messages: historyWithTrigger, - currentMessageId: input.queuedMessage.ts, + currentMessageId: contextThroughMessageId ?? input.queuedMessage.ts, + ...(contextThroughMessageId ? { includeCurrentMessage: true } : {}), }) : undefined; const agentPromptPrefix = input.channelAutoStart?.agentPromptPrefix?.trim(); diff --git a/apps/api/src/handlers/discord/thread-context.ts b/apps/api/src/handlers/discord/thread-context.ts index bd892f31c..4c75ee991 100644 --- a/apps/api/src/handlers/discord/thread-context.ts +++ b/apps/api/src/handlers/discord/thread-context.ts @@ -73,10 +73,13 @@ function formatDiscordThreadContextEntry( export function formatDiscordThreadContext(input: { messages: DiscordThreadHistoryMessage[]; currentMessageId: string; + includeCurrentMessage?: boolean; }): string | undefined { const earlier = input.messages.filter( (message) => - compareDiscordSnowflakes(message.id, input.currentMessageId) < 0 && + (input.includeCurrentMessage + ? compareDiscordSnowflakes(message.id, input.currentMessageId) <= 0 + : compareDiscordSnowflakes(message.id, input.currentMessageId) < 0) && messageHasThreadDeliveryContent(message), ); if (earlier.length === 0) return undefined; @@ -329,6 +332,11 @@ export async function buildDiscordContinuationPrompt(input: { * lives in `channelId` / parent channel. */ replyToChannelId?: string; + /** + * Real Discord message that the synthetic current turn acts on. Include + * history through this message instead of ordering by the synthetic event id. + */ + contextThroughMessageId?: string; }): Promise { const claimUndelivered = input.claimUndelivered !== false; const [historyBase, repliedToMessage] = await Promise.all([ @@ -353,10 +361,16 @@ export async function buildDiscordContinuationPrompt(input: { repliedTo: repliedToMessage, }); + const contextMessageId = + input.contextThroughMessageId ?? input.queuedMessage.ts; + const includeContextMessage = Boolean(input.contextThroughMessageId); + const isInContext = (messageId: string) => + includeContextMessage + ? compareDiscordSnowflakes(messageId, contextMessageId) <= 0 + : compareDiscordSnowflakes(messageId, contextMessageId) < 0; const earlier = history.filter( (message) => - compareDiscordSnowflakes(message.id, input.queuedMessage.ts) < 0 && - messageHasThreadDeliveryContent(message), + isInContext(message.id) && messageHasThreadDeliveryContent(message), ); const ownBotEarlier = earlier.filter( @@ -419,7 +433,7 @@ export async function buildDiscordContinuationPrompt(input: { if ( repliedToMessage && messageHasThreadDeliveryContent(repliedToMessage) && - compareDiscordSnowflakes(repliedToMessage.id, input.queuedMessage.ts) < 0 && + isInContext(repliedToMessage.id) && !(input.botUserId && repliedToMessage.botId === input.botUserId) ) { claimedMessages = mergeDiscordRepliedToMessage({ @@ -466,7 +480,8 @@ export async function buildDiscordContinuationPrompt(input: { }); const threadContext = formatDiscordThreadContext({ messages: threadContextMessages, - currentMessageId: input.queuedMessage.ts, + currentMessageId: contextMessageId, + ...(includeContextMessage ? { includeCurrentMessage: true } : {}), }); const replyingToBlock = diff --git a/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts b/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts new file mode 100644 index 000000000..ea3bd55ed --- /dev/null +++ b/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getConfiguration: vi.fn(), + handleMessage: vi.fn(), +})); + +vi.mock('../../call-roomote-via-emoji.js', () => ({ + getCallRoomoteViaEmojiConfiguration: mocks.getConfiguration, +})); + +vi.mock('./message-entry.js', () => ({ + handleMessageOrAppMentionEvent: mocks.handleMessage, +})); + +import { + handleReactionAddedEvent, + maybeCallRoomoteViaEmoji, +} from './reactions'; + +describe('Slack emoji trigger', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('turns a configured reaction into an app mention in the target thread', async () => { + mocks.getConfiguration.mockResolvedValue({ + emoji: 'white_check_mark', + prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', + }); + const getMessage = vi.fn().mockResolvedValue({ + ts: '1710000000.000100', + thread_ts: '1710000000.000000', + text: 'Please investigate this.', + }); + const context = { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE' }, + slack: { getMessage }, + }; + const event = { + type: 'reaction_added' as const, + user: 'U1', + reaction: 'white_check_mark', + item: { + type: 'message' as const, + channel: 'C1', + ts: '1710000000.000100', + }, + event_ts: '1710000001.000000', + }; + + await expect( + maybeCallRoomoteViaEmoji({ + context: context as never, + event, + }), + ).resolves.toBe(true); + + expect(mocks.handleMessage).toHaveBeenCalledWith({ + context, + event: { + type: 'app_mention', + channel: 'C1', + user: 'U1', + text: '<@UROOMOTE> Act on this\n\nAdditional instructions:\nPrioritize safety.', + ts: '1710000000.000100', + thread_ts: '1710000000.000000', + }, + }); + }); + + it('does nothing when the reaction is not configured', async () => { + mocks.getConfiguration.mockResolvedValue(null); + + await expect( + maybeCallRoomoteViaEmoji({ + context: {} as never, + event: { + type: 'reaction_added', + user: 'U1', + reaction: 'eyes', + item: { type: 'message', channel: 'C1', ts: '1' }, + event_ts: '2', + }, + }), + ).resolves.toBe(false); + }); + + it('gives the configured trigger precedence over thumbs-up suggestion actions', async () => { + mocks.getConfiguration.mockResolvedValue({ + emoji: 'thumbsup', + prompt: 'Act on this', + }); + const context = { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE' }, + slack: { + getMessage: vi.fn().mockResolvedValue({ + ts: '1710000000.000100', + text: 'A suggested task.', + }), + }, + }; + + await handleReactionAddedEvent({ + context: context as never, + event: { + type: 'reaction_added', + user: 'U1', + reaction: 'thumbsup', + item: { type: 'message', channel: 'C1', ts: '1710000000.000100' }, + event_ts: '1710000001.000000', + }, + }); + + expect(mocks.handleMessage).toHaveBeenCalledTimes(1); + expect(mocks.handleMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ text: '<@UROOMOTE> Act on this' }), + }), + ); + }); +}); diff --git a/apps/api/src/handlers/slack/events/reactions.ts b/apps/api/src/handlers/slack/events/reactions.ts index 44d3c8b6a..2c33b5be0 100644 --- a/apps/api/src/handlers/slack/events/reactions.ts +++ b/apps/api/src/handlers/slack/events/reactions.ts @@ -33,6 +33,7 @@ import { } from '@roomote/db/server'; import { apiLogger } from '../../../logging.js'; +import { getCallRoomoteViaEmojiConfiguration } from '../../call-roomote-via-emoji.js'; import { cancelOrphanedWorkItemRunBestEffort } from '../../tasks/orphaned-work-item-run.js'; import { SLACK_SETUP_SUGGESTION_LOCK_PREFIX, @@ -47,6 +48,44 @@ import { type TaskSuggestionReactionLaunchResult, type TaskSuggestionReactionState, } from './task-suggestion-reaction-contention.js'; +import { handleMessageOrAppMentionEvent } from './message-entry.js'; + +export async function maybeCallRoomoteViaEmoji(params: { + context: SlackWebhookContext; + event: SlackReactionAddedEvent; +}): Promise { + const configuration = await getCallRoomoteViaEmojiConfiguration( + params.event.reaction, + ); + if (!configuration) { + return false; + } + + const targetMessage = await params.context.slack.getMessage({ + channel: params.event.item.channel, + messageTs: params.event.item.ts, + }); + if (!targetMessage) { + apiLogger.warn( + `[SlackWebhook] Could not resolve emoji summon target ${params.event.item.channel}:${params.event.item.ts}`, + ); + return true; + } + + await handleMessageOrAppMentionEvent({ + context: params.context, + event: { + type: 'app_mention', + channel: params.event.item.channel, + user: params.event.user, + text: `<@${params.context.slackInstallation.botUserId}> ${configuration.prompt}`, + ts: params.event.item.ts, + thread_ts: targetMessage.thread_ts ?? targetMessage.ts, + }, + }); + + return true; +} async function postSuggestionLaunchFailureMessage(params: { slack: SlackNotifier; @@ -704,7 +743,6 @@ export async function handleReactionAddedEvent(params: { event: SlackReactionAddedEvent; }): Promise { const { context, event } = params; - const reactionNames = await resolveSlackReactionNames(); const isMessageItem = event.item.type === 'message'; if (!isMessageItem) { @@ -718,33 +756,39 @@ export async function handleReactionAddedEvent(params: { return; } - if (!isThumbsUpReaction(event.reaction)) { + if (await maybeCallRoomoteViaEmoji({ context, event })) { return; } - apiLogger.debug( - `[SetupSuggestionLifecycle] Processing thumbs-up reaction team=${context.teamId} channel=${event.item.channel} messageTs=${event.item.ts} reaction=${event.reaction} user=${event.user}`, - ); - const setupSuggestionLockKey = `${SLACK_SETUP_SUGGESTION_LOCK_PREFIX}${event.item.channel}:${event.item.ts}`; - const setupSuggestionHandled = await launchTaskSuggestionTaskWithContention({ - lockKey: setupSuggestionLockKey, - channelId: event.item.channel, - messageTs: event.item.ts, - launch: () => - launchTaskSuggestionTaskFromReaction({ - teamId: context.teamId, - slack: context.slack, - reactionEvent: event, - ackEmoji: reactionNames.ackEmoji, - completionEmoji: reactionNames.completionEmoji, - }), - }); + const reactionNames = await resolveSlackReactionNames(); - if (setupSuggestionHandled) { + if (isThumbsUpReaction(event.reaction)) { apiLogger.debug( - `[SlackWebhook] Setup suggestion reaction handled for ${event.item.channel}:${event.item.ts}`, + `[SetupSuggestionLifecycle] Processing thumbs-up reaction team=${context.teamId} channel=${event.item.channel} messageTs=${event.item.ts} reaction=${event.reaction} user=${event.user}`, ); - return; + const setupSuggestionLockKey = `${SLACK_SETUP_SUGGESTION_LOCK_PREFIX}${event.item.channel}:${event.item.ts}`; + const setupSuggestionHandled = await launchTaskSuggestionTaskWithContention( + { + lockKey: setupSuggestionLockKey, + channelId: event.item.channel, + messageTs: event.item.ts, + launch: () => + launchTaskSuggestionTaskFromReaction({ + teamId: context.teamId, + slack: context.slack, + reactionEvent: event, + ackEmoji: reactionNames.ackEmoji, + completionEmoji: reactionNames.completionEmoji, + }), + }, + ); + + if (setupSuggestionHandled) { + apiLogger.debug( + `[SlackWebhook] Setup suggestion reaction handled for ${event.item.channel}:${event.item.ts}`, + ); + return; + } } apiLogger.debug( diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index 25fc29d09..431272dee 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -35,6 +35,7 @@ const { withContentionMock, claimPendingOutOfBandMock, releaseClaimedOutOfBandMock, + callViaEmojiConfigMock, } = vi.hoisted(() => ({ authAccountsFindFirstMock: vi.fn(), authAccountsFindManyMock: vi.fn(), @@ -89,6 +90,7 @@ const { withContentionMock: vi.fn(), claimPendingOutOfBandMock: vi.fn(), releaseClaimedOutOfBandMock: vi.fn(), + callViaEmojiConfigMock: vi.fn(), })); vi.mock('@roomote/env', () => ({ @@ -266,6 +268,10 @@ vi.mock('../unmentioned-thread-reply.js', () => ({ shouldRouteUnmentionedReplyMock, })); +vi.mock('../../call-roomote-via-emoji.js', () => ({ + getCallRoomoteViaEmojiConfiguration: callViaEmojiConfigMock, +})); + import { teams } from '../index'; function createApp() { @@ -372,6 +378,7 @@ describe('Teams webhook handler', () => { usersFindFirstMock.mockResolvedValue(null); verifyBotFrameworkJwtMock.mockResolvedValue({ payload: {} }); shouldRouteUnmentionedReplyMock.mockResolvedValue(false); + callViaEmojiConfigMock.mockResolvedValue(null); withContentionMock.mockImplementation( async ( _key: string, @@ -401,6 +408,70 @@ describe('Teams webhook handler', () => { expect(insertMock).not.toHaveBeenCalled(); }); + it('turns a configured reaction into a thread message', async () => { + callViaEmojiConfigMock.mockResolvedValue({ + emoji: 'thumbsup', + prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', + }); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer valid-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + type: 'messageReaction', + id: 'reaction-1', + text: undefined, + entities: undefined, + replyToId: 'activity-root', + reactionsAdded: [{ type: 'like' }], + }), + ), + }); + + expect(response.status).toBe(200); + expect(queueCommunicationMessageMock).toHaveBeenCalledWith( + 'teams', + 77, + expect.objectContaining({ + provider: 'teams', + text: 'Act on this Additional instructions: Prioritize safety.', + ts: 'reaction-1', + threadTs: 'activity-root', + }), + ); + }); + + it('ignores reaction types outside the Teams native set', async () => { + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer valid-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + type: 'messageReaction', + id: 'reaction-unsupported', + text: undefined, + entities: undefined, + replyToId: 'activity-root', + reactionsAdded: [{ type: 'white_check_mark' }], + }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: 'reaction_not_configured', + }); + expect(callViaEmojiConfigMock).not.toHaveBeenCalled(); + expect(queueCommunicationMessageMock).not.toHaveBeenCalled(); + }); + it('queues Teams message activities for matching active task runs', async () => { teamsUserMappingFindFirstMock.mockResolvedValueOnce({ userId: 'mapped-user-1', diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index 62d43a47c..91418324c 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -11,6 +11,7 @@ import { getTeamsActivityTeamId, getTeamsActivityTenantId, isTeamsBotAuthoredActivity, + isTeamsNativeReactionType, isTeamsTaskEntryActivity, parseTeamsActivity, teamsActivityToQueuedCommunicationMessage, @@ -65,6 +66,7 @@ import { } from '@roomote/cloud-agents/server'; import { apiLogger } from '../../logging.js'; +import { getCallRoomoteViaEmojiConfiguration } from '../call-roomote-via-emoji.js'; import { syncActingUserForInboundMessage } from '../tasks/acting-user-sync.js'; import { attachOutOfBandContextToCommunicationMessage, @@ -1648,7 +1650,7 @@ teams.post('/', async (c) => { ); } - const activity = parsed.data; + let activity = parsed.data; const verificationError = await verifyTeamsWebhookAuthorization({ authorizationHeader: c.req.header('authorization'), activity, @@ -1677,6 +1679,48 @@ teams.post('/', async (c) => { return c.json({ ok: true, ignored: 'bot_activity' }); } + if (activity.type === 'messageReaction') { + let configuration: Awaited< + ReturnType + > = null; + for (const reaction of activity.reactionsAdded ?? []) { + if (!isTeamsNativeReactionType(reaction.type)) { + continue; + } + configuration = await getCallRoomoteViaEmojiConfiguration(reaction.type); + if (configuration) { + break; + } + } + + if (!configuration) { + return c.json({ ok: true, ignored: 'reaction_not_configured' }); + } + + const targetMessageId = activity.replyToId?.trim(); + if (!targetMessageId) { + return c.json({ ok: true, ignored: 'reaction_target_missing' }); + } + + const mentionName = activity.recipient?.name?.trim() || PRODUCT_NAME; + const mentionText = `${mentionName}`; + activity = { + ...activity, + type: 'message', + id: activity.id ?? randomUUID(), + text: `${mentionText} ${configuration.prompt}`, + replyToId: targetMessageId, + entities: [ + { + type: 'mention', + text: mentionText, + mentioned: activity.recipient, + }, + ], + reactionsAdded: undefined, + }; + } + await persistTeamsInstallationFromActivity(activity); const mappedUserId = await findMappedTeamsUserId(activity); diff --git a/apps/discord-gateway/src/dispatch.test.ts b/apps/discord-gateway/src/dispatch.test.ts index 8d8060f04..16c71fbb8 100644 --- a/apps/discord-gateway/src/dispatch.test.ts +++ b/apps/discord-gateway/src/dispatch.test.ts @@ -50,6 +50,90 @@ describe('handleGatewayDispatch', () => { }); }); + it('enqueues message reactions with a deterministic event id', async () => { + const enqueue = vi.fn().mockResolvedValue(true); + const rest = { post: vi.fn() }; + const payload = { + user_id: 'user-1', + channel_id: 'channel-1', + message_id: 'message-1', + guild_id: 'guild-1', + emoji: { id: null, name: 'white_check_mark' }, + member: { + user: { id: 'user-1', username: 'matt' }, + }, + }; + + await expect( + handleGatewayDispatch( + { t: 'MESSAGE_REACTION_ADD', s: 42, d: payload }, + { + enqueue, + rest, + now, + getSessionDedupeScope: () => 'session-a', + }, + ), + ).resolves.toBe('enqueued'); + + expect(enqueue).toHaveBeenCalledWith({ + eventId: 'channel-1:message-1:user-1:white_check_mark:session-a:42', + eventType: 'MESSAGE_REACTION_ADD', + payload, + receivedAt: '2026-07-12T12:00:00.000Z', + }); + }); + + it('keeps separate reaction adds distinct while deduping Gateway replays', async () => { + const eventIds = new Set(); + const enqueue = vi.fn(async (envelope: DiscordInboundEnvelope) => { + if (eventIds.has(envelope.eventId)) return false; + eventIds.add(envelope.eventId); + return true; + }); + const rest = { post: vi.fn() }; + const dependencies = { + enqueue, + rest, + now, + getSessionDedupeScope: () => 'session-a', + }; + const payload = { + user_id: 'user-1', + channel_id: 'channel-1', + message_id: 'message-1', + emoji: { id: null, name: 'white_check_mark' }, + }; + + await expect( + handleGatewayDispatch( + { t: 'MESSAGE_REACTION_ADD', s: 42, d: payload }, + dependencies, + ), + ).resolves.toBe('enqueued'); + await expect( + handleGatewayDispatch( + { t: 'MESSAGE_REACTION_ADD', s: 42, d: payload }, + dependencies, + ), + ).resolves.toBe('duplicate'); + await expect( + handleGatewayDispatch( + { t: 'MESSAGE_REACTION_ADD', s: 43, d: payload }, + dependencies, + ), + ).resolves.toBe('enqueued'); + await expect( + handleGatewayDispatch( + { t: 'MESSAGE_REACTION_ADD', s: 42, d: payload }, + { + ...dependencies, + getSessionDedupeScope: () => 'session-b', + }, + ), + ).resolves.toBe('enqueued'); + }); + it('normalizes a managed-role mention into a canonical bot mention', async () => { const enqueue = vi.fn().mockResolvedValue(true); const rest = { post: vi.fn() }; diff --git a/apps/discord-gateway/src/dispatch.ts b/apps/discord-gateway/src/dispatch.ts index 2bebb6f58..c7a8f8590 100644 --- a/apps/discord-gateway/src/dispatch.ts +++ b/apps/discord-gateway/src/dispatch.ts @@ -8,6 +8,7 @@ import type { type RawDispatch = { t?: string | null; + s?: number | null; d?: unknown; }; @@ -29,6 +30,13 @@ type RawInteraction = { data?: { name?: string }; }; +type RawReaction = { + user_id?: string; + channel_id?: string; + message_id?: string; + emoji?: { id?: string | null; name?: string | null }; +}; + type DispatchDependencies = { rest: Pick; enqueue: (envelope: DiscordInboundEnvelope) => Promise; @@ -39,6 +47,8 @@ type DispatchDependencies = { /** The bot's managed role id for a guild, when known. */ getBotRoleId?: (guildId: string) => string | null | undefined; getBotUsername?: () => string | undefined; + /** Stable across resumes, distinct after Discord creates a new session. */ + getSessionDedupeScope?: () => string | undefined; /** * Forward an unmentioned guild message when Gateway channel metadata could * not be resolved. The durable API consumer performs its own authoritative @@ -125,6 +135,9 @@ function eventTypeFor(packet: RawDispatch): DiscordInboundEventType | null { if (packet.t === 'INTERACTION_CREATE') { return 'INTERACTION_CREATE'; } + if (packet.t === 'MESSAGE_REACTION_ADD') { + return 'MESSAGE_REACTION_ADD'; + } return null; } @@ -270,8 +283,30 @@ export async function handleGatewayDispatch( }; } - const payload = packet.d as RawMessage & RawInteraction; - if (!payload.id) { + const payload = packet.d as RawMessage & RawInteraction & RawReaction; + const dispatchSequence = + typeof packet.s === 'number' && Number.isSafeInteger(packet.s) + ? packet.s + : null; + const sessionDedupeScope = dependencies.getSessionDedupeScope?.(); + const reactionEventId = + eventType === 'MESSAGE_REACTION_ADD' && + dispatchSequence !== null && + payload.channel_id && + payload.message_id && + payload.user_id && + payload.emoji?.name + ? [ + payload.channel_id, + payload.message_id, + payload.user_id, + payload.emoji.id ?? payload.emoji.name, + ...(sessionDedupeScope ? [sessionDedupeScope] : []), + dispatchSequence, + ].join(':') + : null; + const eventId = payload.id ?? reactionEventId; + if (!eventId) { return 'ignored'; } @@ -281,7 +316,7 @@ export async function handleGatewayDispatch( : undefined; const enqueued = await dependencies.enqueue({ - eventId: payload.id, + eventId, eventType, payload: packet.d, receivedAt: (dependencies.now?.() ?? new Date()).toISOString(), diff --git a/apps/discord-gateway/src/gateway-resume-store.test.ts b/apps/discord-gateway/src/gateway-resume-store.test.ts index eaf02f8af..6de7dd8e1 100644 --- a/apps/discord-gateway/src/gateway-resume-store.test.ts +++ b/apps/discord-gateway/src/gateway-resume-store.test.ts @@ -52,6 +52,35 @@ describe('DiscordGatewayResumeStore', () => { }); }); + it('keeps the session dedupe scope stable across process restarts', async () => { + const firstStore = new DiscordGatewayResumeStore( + 'fingerprint', + createRepository(), + 60_000, + ); + const secondStore = new DiscordGatewayResumeStore( + 'fingerprint', + createRepository(), + 60_000, + ); + + await firstStore.retrieve(0); + await secondStore.retrieve(0); + + expect(firstStore.getSessionDedupeScope(0)).toBe( + secondStore.getSessionDedupeScope(0), + ); + expect(firstStore.getSessionDedupeScope(0)).not.toContain('session-1'); + + await secondStore.update(0, { + ...persistedSession, + sessionId: 'session-2', + }); + expect(secondStore.getSessionDedupeScope(0)).not.toBe( + firstStore.getSessionDedupeScope(0), + ); + }); + it('persists a new session immediately and checkpoints only acknowledged dispatches', async () => { const repository = createRepository(); repository.find.mockResolvedValueOnce(null); diff --git a/apps/discord-gateway/src/gateway-resume-store.ts b/apps/discord-gateway/src/gateway-resume-store.ts index 440584c36..d38004123 100644 --- a/apps/discord-gateway/src/gateway-resume-store.ts +++ b/apps/discord-gateway/src/gateway-resume-store.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import type { SessionInfo } from '@discordjs/ws'; import { clearDiscordGatewayResumeState, @@ -78,6 +80,16 @@ export class DiscordGatewayResumeStore { return this.committedSession(shardId); } + getSessionDedupeScope(shardId: number): string | undefined { + const sessionId = this.sessions.get(shardId)?.sessionId; + if (!sessionId) return undefined; + + return createHash('sha256') + .update(`${this.tokenFingerprint}:${sessionId}`) + .digest('hex') + .slice(0, 16); + } + async update(shardId: number, session: SessionInfo | null): Promise { const previous = this.sessions.get(shardId) ?? null; this.loadedShards.add(shardId); diff --git a/apps/discord-gateway/src/gateway-session.test.ts b/apps/discord-gateway/src/gateway-session.test.ts index 90ca3aa9b..1013dcc1d 100644 --- a/apps/discord-gateway/src/gateway-session.test.ts +++ b/apps/discord-gateway/src/gateway-session.test.ts @@ -13,11 +13,13 @@ import { } from './gateway-session'; describe('Discord Gateway intents', () => { - it('subscribes to guilds, guild messages, DMs, and message content', () => { + it('subscribes to messages and reactions in guilds and DMs', () => { expect(DISCORD_GATEWAY_INTENTS).toEqual([ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.DirectMessages, + GatewayIntentBits.DirectMessageReactions, GatewayIntentBits.MessageContent, ]); }); @@ -112,7 +114,9 @@ describe('DiscordGatewaySession durable resume wiring', () => { intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages | + GatewayIntentBits.GuildMessageReactions | GatewayIntentBits.DirectMessages | + GatewayIntentBits.DirectMessageReactions | GatewayIntentBits.MessageContent, }); expect(repository.find).toHaveBeenCalledWith({ @@ -175,6 +179,56 @@ describe('DiscordGatewaySession durable resume wiring', () => { sequence: 43, }); + const reactionPayload = { + user_id: 'user-1', + channel_id: 'dm-1', + message_id: 'message-1', + emoji: { id: null, name: 'white_check_mark' }, + }; + await activeManagerOptions.updateSessionInfo(0, { + ...persisted, + sequence: 44, + }); + const firstSessionScope = resumeStore.getSessionDedupeScope(0); + await listeners.get(WebSocketShardEvents.Dispatch)?.({ + shardId: 0, + data: { + op: 0, + s: 44, + t: 'MESSAGE_REACTION_ADD', + d: reactionPayload, + }, + }); + expect(queue.enqueue).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + eventId: `dm-1:message-1:user-1:white_check_mark:${firstSessionScope}:44`, + }), + ); + + await activeManagerOptions.updateSessionInfo(0, { + ...persisted, + sessionId: 'session-2', + sequence: 44, + }); + const secondSessionScope = resumeStore.getSessionDedupeScope(0); + expect(secondSessionScope).not.toBe(firstSessionScope); + await listeners.get(WebSocketShardEvents.Dispatch)?.({ + shardId: 0, + data: { + op: 0, + s: 44, + t: 'MESSAGE_REACTION_ADD', + d: reactionPayload, + }, + }); + expect(queue.enqueue).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + eventId: `dm-1:message-1:user-1:white_check_mark:${secondSessionScope}:44`, + }), + ); + await session.disconnect(); expect(destroy).toHaveBeenCalledOnce(); @@ -211,6 +265,7 @@ describe('DiscordGatewaySession durable resume wiring', () => { const resumeStore = { retrieve: vi.fn(async () => null), update: vi.fn(async () => undefined), + getSessionDedupeScope: vi.fn(() => 'session-scope'), acknowledgeDispatch: vi.fn(() => true), recordHeartbeat: vi.fn(async () => undefined), flush: vi.fn(async () => undefined), diff --git a/apps/discord-gateway/src/gateway-session.ts b/apps/discord-gateway/src/gateway-session.ts index 561be968f..f5ce1a36f 100644 --- a/apps/discord-gateway/src/gateway-session.ts +++ b/apps/discord-gateway/src/gateway-session.ts @@ -16,7 +16,9 @@ import type { GatewayStatusStore } from './status'; export const DISCORD_GATEWAY_INTENTS = [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.DirectMessages, + GatewayIntentBits.DirectMessageReactions, GatewayIntentBits.MessageContent, ]; @@ -182,6 +184,8 @@ export class DiscordGatewaySession { await handleGatewayDispatch(data, { getBotRoleId: (guildId) => this.botRoleIds.get(guildId), getBotUsername: () => this.botUsername, + getSessionDedupeScope: () => + resumeStore.getSessionDedupeScope(shardId), rest, getBotUserId: () => this.botUserId, getCachedChannel: (channelId) => this.channelCache.get(channelId), diff --git a/apps/discord-gateway/src/inbound-queue.ts b/apps/discord-gateway/src/inbound-queue.ts index af7713c98..9ab0175c9 100644 --- a/apps/discord-gateway/src/inbound-queue.ts +++ b/apps/discord-gateway/src/inbound-queue.ts @@ -43,7 +43,10 @@ redis.call('HDEL', KEYS[3], ARGV[1]) return deadLetterId `; -export type DiscordInboundEventType = 'MESSAGE_CREATE' | 'INTERACTION_CREATE'; +export type DiscordInboundEventType = + | 'MESSAGE_CREATE' + | 'INTERACTION_CREATE' + | 'MESSAGE_REACTION_ADD'; export type DiscordInboundEnvelope = { eventId: string; diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 185bc476a..87d59d84a 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -103,6 +103,31 @@ Admins can also manage custom automations from a Roomote task through the `manage_custom_automations` tool: list, resolve a schedule, create, update, delete, or run an enabled automation immediately. +## Call Roomote via emoji + +Use **Call Roomote via emoji** to let teammates summon Roomote by reacting to a +message in Slack, Discord, or Microsoft Teams. An admin chooses the emoji name, +such as `:white_check_mark:`, and can add optional instructions that apply to +every request started this way. + +When the configured reaction is added, Roomote handles it like a teammate +replied in that thread with `@Roomote Act on this`. Existing Roomote task +threads continue the active task; other threads start a task with the thread's +conversation as context. Optional automation instructions are added after the +default `Act on this` prompt. + +The teammate adding the reaction must have a linked Roomote account, just as +they would when mentioning Roomote directly from that communications provider. + +Provider support differs slightly: + +- Slack supports standard and workspace custom emoji reactions. +- Discord supports standard and server custom emoji reactions. +- Microsoft Teams sends reaction activities only for messages posted by + Roomote. Teams supports its native `like`, `heart`, `laugh`, `surprised`, + `sad`, and `angry` reactions; choose an equivalent configured emoji such as + `:thumbsup:` for Like or `:heart:` for Heart. + ## Channel automations The channel section starts with **Auto-respond to channels**. diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx index f59dfd7ab..cf12ef546 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx @@ -32,6 +32,9 @@ import { } from './ChannelAutoStartEditor'; const baseFormState: FormState = { + callRoomoteViaEmojiEnabled: false, + callRoomoteViaEmojiName: '', + callRoomoteViaEmojiInstructions: '', reviewerEnabled: false, reviewerEnvironmentScope: 'all' as const, reviewerEnvironmentIds: [] as string[], @@ -91,6 +94,26 @@ const baseFormState: FormState = { }; describe('Automations selection helpers', () => { + it('includes emoji trigger settings in its save input', () => { + const saveInput = buildAutomationSettingsSaveInput( + { + ...baseFormState, + callRoomoteViaEmojiEnabled: true, + callRoomoteViaEmojiName: ' :white_check_mark: ', + callRoomoteViaEmojiInstructions: ' Prioritize safety. ', + }, + baseFormState, + 'callRoomoteViaEmoji', + ); + + expect(saveInput).toMatchObject({ + savingAutomation: 'callRoomoteViaEmoji', + callRoomoteViaEmojiEnabled: true, + callRoomoteViaEmojiName: ':white_check_mark:', + callRoomoteViaEmojiInstructions: 'Prioritize safety.', + }); + }); + it('keeps author scope specific when the last author is removed', () => { const next = applyReviewerAuthorSelection( { diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index eede21ef9..16f6f3192 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -71,6 +71,9 @@ const state = vi.hoisted(() => ({ conflictResolverLabel: 'roomote:auto-resolve-conflicts', conflictResolverInstructions: null, reviewCodeInstructions: null as string | null, + callRoomoteViaEmojiEnabled: false, + callRoomoteViaEmojiName: null as string | null, + callRoomoteViaEmojiInstructions: null as string | null, channelAutoStartSlackChannels: [ { channelId: 'C123BUGS', @@ -554,6 +557,27 @@ describe('AutomationsSettings', () => { expect(screen.queryByText('Beta')).not.toBeInTheDocument(); }); + it('configures Call Roomote via emoji with a name and instructions', async () => { + render(); + + fireEvent.click( + await screen.findByRole('button', { + name: 'Set up Call Roomote via emoji', + }), + ); + fireEvent.click( + screen.getByRole('switch', { + name: 'Allow emoji reactions to call Roomote', + }), + ); + + expect(screen.getByLabelText('Emoji name')).toHaveAttribute( + 'placeholder', + ':white_check_mark:', + ); + expect(screen.getByLabelText('Additional instructions')).toBeVisible(); + }); + it('shows additional instructions for Review Code', async () => { state.settingsQuery.data.reviewer.enabled = true; state.settingsQuery.data.settings.reviewer.enabled = true; @@ -806,6 +830,9 @@ describe('AutomationsSettings', () => { fireEvent.click(await screen.findByRole('option', { name: 'Operations' })); expect(screen.getByText('Triage Sentry Issues')).toBeInTheDocument(); expect(screen.queryByText('Review Code')).not.toBeInTheDocument(); + expect( + screen.queryByText('Call Roomote via emoji'), + ).not.toBeInTheDocument(); }); it('shows independent structural skeletons for custom and built-in automations', () => { diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index e054eff12..f029a0710 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -102,6 +102,7 @@ import { SelectSeparator, SelectTrigger, SelectValue, + Smile, MessagesSquare, Skeleton, SquarePen, @@ -124,6 +125,8 @@ type FieldErrors = Partial< | 'conflictResolverLabel' | 'conflictResolverMaxPrAgeDays' | 'conflictResolverInstructions' + | 'callRoomoteViaEmojiName' + | 'callRoomoteViaEmojiInstructions' | 'channelAutoStartSlackChannels' | 'channelAutoStartDiscordChannels' | 'channelAutoStartInstructions' @@ -491,6 +494,15 @@ const SCHEDULE_ONLY_AUTOMATIONS_BY_ID = Object.fromEntries( >; const AUTOMATION_DEFINITIONS: Record = { + callRoomoteViaEmoji: { + id: 'callRoomoteViaEmoji', + label: 'Call Roomote via emoji', + description: + 'Start or continue work in a Slack, Discord, or Teams thread by reacting with an emoji.', + icon: Smile, + category: 'communication', + searchTerms: ['Slack', 'Discord', 'Teams'], + }, channelAutoStart: { id: 'channelAutoStart', label: 'Auto-respond to channels', @@ -573,6 +585,8 @@ const HASH_ALIAS_TO_AUTOMATION_ID: Record = { ]), ), 'auto-respond-channels': 'channelAutoStart', + 'call-roomote-via-emoji': 'callRoomoteViaEmoji', + 'emoji-trigger': 'callRoomoteViaEmoji', autorespondchannels: 'channelAutoStart', 'auto-start-tasks': 'channelAutoStart', channelautostart: 'channelAutoStart', @@ -709,6 +723,9 @@ function mapSettingsToFormState( }>; }; reviewCodeInstructions: string | null; + callRoomoteViaEmojiEnabled: boolean; + callRoomoteViaEmojiName: string | null; + callRoomoteViaEmojiInstructions: string | null; conflictResolverFrequency: ConflictResolverFrequency; conflictResolverMaxPrAgeDays: ConflictResolverMaxPrAgeDays; conflictResolverLabel: string; @@ -775,6 +792,10 @@ function mapSettingsToFormState( }, ): FormState { return { + callRoomoteViaEmojiEnabled: settings.callRoomoteViaEmojiEnabled, + callRoomoteViaEmojiName: settings.callRoomoteViaEmojiName ?? '', + callRoomoteViaEmojiInstructions: + settings.callRoomoteViaEmojiInstructions ?? '', reviewerEnabled: settings.reviewer.enabled, reviewerEnvironmentScope: 'all', reviewerEnvironmentIds: [], @@ -1856,6 +1877,17 @@ export function AutomationsSettings() { return next; }); } + + if ( + result.fieldErrors.callRoomoteViaEmojiName || + result.fieldErrors.callRoomoteViaEmojiInstructions + ) { + setOpenAutomationIds((prev) => { + const next = new Set(prev); + next.add('callRoomoteViaEmoji'); + return next; + }); + } return; } @@ -1981,6 +2013,7 @@ export function AutomationsSettings() { if (!formState || !savedState) { return { + callRoomoteViaEmoji: false, channelAutoStart: false, managerChannel: false, managerStats: false, @@ -1997,6 +2030,11 @@ export function AutomationsSettings() { } return { + callRoomoteViaEmoji: isAutomationDirty( + formState, + savedState, + 'callRoomoteViaEmoji', + ), channelAutoStart: isAutomationDirty( formState, savedState, @@ -2195,6 +2233,8 @@ export function AutomationsSettings() { CHANNEL_AUTO_START_LAUNCH_MODE_OPTIONS; const showChannelAutoStartLaunchModePicker = false; const reviewerIsEnabled = formState?.reviewerEnabled ?? false; + const callRoomoteViaEmojiIsEnabled = + formState?.callRoomoteViaEmojiEnabled ?? false; const conflictResolverIsEnabled = formState?.conflictResolverFrequency !== 'off'; const channelAutoStartIsEnabled = hasConfiguredChannelAutoStartRows( @@ -2576,6 +2616,7 @@ export function AutomationsSettings() { ); const iconEnabled = { + callRoomoteViaEmoji: callRoomoteViaEmojiIsEnabled, channelAutoStart: channelAutoStartIsEnabled, managerChannel: managerChannelIsEnabled, managerStats: managerStatsIsEnabled, @@ -2753,7 +2794,7 @@ export function AutomationsSettings() { @@ -2801,6 +2842,115 @@ export function AutomationsSettings() { No available automations match these filters.

) : null} + + setAutomationOpen('callRoomoteViaEmoji', open) + } + iconEnabled={iconEnabled.callRoomoteViaEmoji} + footer={ + saveAgent('callRoomoteViaEmoji')} + onReset={() => resetAgent('callRoomoteViaEmoji')} + /> + } + > +
+
+ + setFormState((prev) => + prev ? { ...prev, callRoomoteViaEmojiEnabled } : prev, + ) + } + /> + +
+ + {callRoomoteViaEmojiIsEnabled ? ( +
+
+ + + setFormState((prev) => + prev + ? { + ...prev, + callRoomoteViaEmojiName: event.target.value, + } + : prev, + ) + } + placeholder=":white_check_mark:" + /> +

+ Enter the reaction name, with or without surrounding + colons. Microsoft Teams supports its native Like, Heart, + Laugh, Surprised, Sad, and Angry reactions on messages + posted by Roomote. +

+ {fieldErrors.callRoomoteViaEmojiName ? ( +

+ {fieldErrors.callRoomoteViaEmojiName} +

+ ) : null} +
+ +
+ +