From f9cc1e53467c061dd4061d1f6829ba217970d2fb Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:38:15 +0000 Subject: [PATCH 1/2] fix: avoid reacting to inbound Slack reactions --- .../server/fast-agent/__tests__/fast-agent-prompt.test.ts | 7 +++++++ .../src/server/fast-agent/fast-agent-prompt.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts index b4995a9f5..91667e4b0 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts @@ -502,6 +502,13 @@ describe('buildFastAgentSystemPrompt', () => { 'Call "ignore_event" only when the event is duplicate, lifecycle-only, machinery-only, or a routine log that adds nothing useful', ); expect(prompt).not.toContain('Do not call "ignore_event"'); + expect(prompt).toContain('Do not use the reaction tool'); + expect(prompt).toContain( + 'an inbound Slack reaction event is not itself a reactable message surface', + ); + expect(prompt).toContain( + 'If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above', + ); }); it('requires a visible closeout for visibility-required platform events', () => { diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index d270fa312..64e91011e 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts @@ -257,7 +257,7 @@ ${ : '- No failed-start retry tool is available for this event. Report or ignore it without retrying.' } - Launching creates a separate delegated task; it does not retry the task associated with this event. -- Do not use the reaction tool because a platform event has no incoming chat message to react to. +- Do not use the reaction tool because a platform event has no incoming chat message to react to. In particular, an inbound Slack reaction event is not itself a reactable message surface. If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above. ${ platformEventKind === 'automation' ? `- Execute the automation prompt now. Use integrations directly when sufficient, and launch a task only when repository or workspace execution is actually required. The configured model is a delegated-task default, not the Fast inference model. From c151126b7782f144a982f66cc103af92e740d347 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:18:00 +0000 Subject: [PATCH 2/2] feat: support Fast reply reactions across chat providers --- .changeset/fast-reply-reactions.md | 5 + .../handlers/discord/__tests__/index.test.ts | 104 +++++++++++++ apps/api/src/handlers/discord/index.ts | 93 ++++++++++- .../slack/events/fast-agent-reaction.test.ts | 22 ++- .../slack/events/fast-agent-reaction.ts | 39 ++--- .../handlers/teams/__tests__/index.test.ts | 146 ++++++++++++++++++ apps/api/src/handlers/teams/index.ts | 104 ++++++++++++- .../handlers/telegram/__tests__/index.test.ts | 120 ++++++++++++++ apps/api/src/handlers/telegram/index.ts | 104 ++++++++++++- apps/docs/automations.mdx | 5 + apps/docs/communications.mdx | 20 +++ .../providers/communications/telegram.mdx | 6 +- .../__tests__/fast-agent-prompt.test.ts | 2 +- .../__tests__/fast-agent-service.test.ts | 34 ++++ .../fast-agent/fast-agent-conversation.ts | 21 +++ .../server/fast-agent/fast-agent-prompt.ts | 2 +- .../server/fast-agent/fast-agent-service.ts | 7 + .../src/__tests__/teams-activity.test.ts | 17 ++ .../src/__tests__/telegram-update.test.ts | 15 ++ packages/communication/src/teams-activity.ts | 9 ++ packages/communication/src/telegram-update.ts | 21 +++ .../lib/fast-agent-provider-message.test.ts | 57 ++++++- .../server/lib/fast-agent-provider-message.ts | 32 ++++ .../lib/fast-agent-surface-reply.test.ts | 117 ++++++++++++++ .../server/lib/fast-agent-surface-reply.ts | 100 +++++++----- 25 files changed, 1118 insertions(+), 84 deletions(-) create mode 100644 .changeset/fast-reply-reactions.md diff --git a/.changeset/fast-reply-reactions.md b/.changeset/fast-reply-reactions.md new file mode 100644 index 000000000..e9be22025 --- /dev/null +++ b/.changeset/fast-reply-reactions.md @@ -0,0 +1,5 @@ +--- +"@roomote/api": patch +--- + +Let linked Fast session owners respond to Roomote replies with emoji reactions across Slack, Discord, Microsoft Teams, and Telegram. diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 2cebcf495..a8dd25219 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -62,9 +62,11 @@ const mocks = vi.hoisted(() => ({ acquireFastTurnLock: vi.fn(), answerFast: vi.fn(), hasFastSession: vi.fn(), + findFastMessageSession: vi.fn(), findFastReplySession: vi.fn(), isFastProviderMessage: vi.fn(), recordProviderMessage: vi.fn(), + queueFastSurfaceReply: vi.fn(), })); vi.mock('../../account-link-help.js', () => ({ @@ -110,9 +112,11 @@ vi.mock('@roomote/sdk/server', () => ({ upsertDiscordInstallation: mocks.upsertInstallation, enqueueDiscordGatewayEvent: mocks.enqueueGatewayEvent, claimPendingPrReviewActionsForThread: vi.fn(async () => []), + findFastAgentSessionForProviderMessage: mocks.findFastMessageSession, findFastAgentSessionForProviderReply: mocks.findFastReplySession, isFastAgentProviderMessage: mocks.isFastProviderMessage, recordFastAgentConversationMessageBestEffort: mocks.recordProviderMessage, + queueFastAgentSurfaceReply: mocks.queueFastSurfaceReply, resolveUserMcpServerConfigs: vi.fn(async () => ({})), })); @@ -182,6 +186,10 @@ vi.mock('../callback-actions.js', () => ({ vi.mock('@roomote/cloud-agents/server', () => ({ acquireFastAgentTurnLock: mocks.acquireFastTurnLock, answerFastAgentQuestion: mocks.answerFast, + buildFastAgentReactionExternalInputQuestion: vi.fn( + (input: unknown) => + `${JSON.stringify(input)}`, + ), resolveApiBaseUrl: () => 'https://roomote.example.com', getTaskUrl: mocks.getTaskUrl, hasFastAgentSession: mocks.hasFastSession, @@ -323,9 +331,11 @@ describe('Discord Gateway event handler', () => { ); mocks.answerFast.mockResolvedValue('A quick answer'); mocks.hasFastSession.mockResolvedValue(false); + mocks.findFastMessageSession.mockResolvedValue(null); mocks.findFastReplySession.mockResolvedValue(null); mocks.isFastProviderMessage.mockResolvedValue(false); mocks.recordProviderMessage.mockResolvedValue(true); + mocks.queueFastSurfaceReply.mockResolvedValue(true); mocks.reply.mockResolvedValue({ messageId: 'reply-1' }); mocks.createDirectMessage.mockResolvedValue({ id: 'dm-private-1' }); mocks.createThreadFromMessage.mockResolvedValue({ @@ -509,6 +519,7 @@ describe('Discord Gateway event handler', () => { ); expect(mocks.startNewTask).not.toHaveBeenCalled(); expect(mocks.queueMessage).not.toHaveBeenCalled(); + expect(mocks.queueFastSurfaceReply).not.toHaveBeenCalled(); }); it('answers a configured reaction through the fast agent when the reacted-on message cannot be fetched', async () => { @@ -591,6 +602,99 @@ describe('Discord Gateway event handler', () => { }), ); expect(mocks.callViaEmojiConfig).not.toHaveBeenCalled(); + expect(mocks.queueFastSurfaceReply).not.toHaveBeenCalled(); + }); + + it('queues an unconfigured reaction on the owner’s bound Fast message', async () => { + mocks.getChannel.mockResolvedValue({ + id: 'thread-1', + name: 'Task thread', + type: 11, + guildId: 'guild-1', + parentId: 'channel-1', + }); + mocks.findFastMessageSession.mockResolvedValue({ + id: 'fast-session-1', + userId: 'roomote-user-1', + conversation: { + surface: 'discord', + workspaceId: 'guild-1', + conversationId: 'thread-1', + replyTarget: { channelId: 'channel-1', threadId: 'thread-1' }, + }, + }); + + const response = await postEvent({ + eventId: 'thread-1:message-1:discord-user-1:heart', + eventType: 'MESSAGE_REACTION_ADD', + receivedAt: '2026-07-12T15:00:00.000Z', + payload: { + user_id: 'discord-user-1', + channel_id: 'thread-1', + message_id: 'message-1', + guild_id: 'guild-1', + emoji: { id: null, name: 'heart' }, + member: { + nick: 'Matt', + user: { id: 'discord-user-1', username: 'matt' }, + }, + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + fastReactionQueued: true, + }); + expect(mocks.findFastMessageSession).toHaveBeenCalledWith({ + provider: 'discord', + workspaceId: 'guild-1', + channelId: 'channel-1', + threadId: 'thread-1', + messageId: 'message-1', + }); + expect(mocks.queueFastSurfaceReply).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'fast-session-1', + userId: 'roomote-user-1', + currentMessageId: expect.stringContaining('discord-reaction:'), + replyToMessageId: 'message-1', + externalInput: expect.objectContaining({ + provider: 'discord', + reactions: [{ name: 'heart' }], + }), + }), + ); + }); + + it('rejects a reaction from a different Fast session owner', async () => { + mocks.findFastMessageSession.mockResolvedValue({ + id: 'fast-session-1', + userId: 'another-roomote-user', + conversation: { + surface: 'discord', + workspaceId: 'dm', + conversationId: 'dm-1', + replyTarget: { channelId: 'dm-1' }, + }, + }); + + const response = await postEvent({ + eventId: 'dm-1:message-1:discord-user-1:heart', + eventType: 'MESSAGE_REACTION_ADD', + receivedAt: '2026-07-12T15:00:00.000Z', + payload: { + user_id: 'discord-user-1', + channel_id: 'dm-1', + message_id: 'message-1', + emoji: { id: null, name: 'heart' }, + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: 'discord_fast_session_user_mismatch', + }); + expect(mocks.queueFastSurfaceReply).not.toHaveBeenCalled(); }); it('rejects an invalid Gateway secret before claiming the event', async () => { diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index 23e08466c..0cb2408a9 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -25,7 +25,12 @@ import { setLatestInboundMessageId, } from '@roomote/communication/messages'; import { reactionEmojiMatches } from '@roomote/communication/reaction-emoji'; -import { getTaskUrl, hasFastAgentSession } from '@roomote/cloud-agents/server'; +import { + buildFastAgentReactionExternalInputQuestion, + getTaskUrl, + hasFastAgentSession, + type FastAgentReactionExternalInput, +} from '@roomote/cloud-agents/server'; import { MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE, RunStatus, @@ -37,8 +42,10 @@ import { consumeDiscordLinkCode, findDiscordInstallationByGuildId, findDiscordMappedUserId, + findFastAgentSessionForProviderMessage, findFastAgentSessionForProviderReply, isFastAgentProviderMessage, + queueFastAgentSurfaceReply, restoreDiscordLinkCode, upsertDiscordInstallation, upsertDiscordUserMapping, @@ -308,7 +315,89 @@ async function processDiscordGatewayEvent( reaction.emoji.name, ); if (!configuration) { - return { ok: true, ignored: 'reaction_not_configured' }; + const channel = await resolveDiscordChannelContext( + resolved.provider, + reaction.channel_id, + ); + const metadata = discordMetadataForChannel({ + channel, + messageId: reaction.message_id, + }); + const session = await findFastAgentSessionForProviderMessage({ + provider: 'discord', + workspaceId: channel.guildId ?? 'dm', + channelId: metadata.communicationChannelId, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + messageId: reaction.message_id, + }); + if (!session) { + return { ok: true, ignored: 'reaction_not_configured' }; + } + + const senderUserId = await findDiscordMappedUserId(reaction.user_id); + if (!senderUserId) { + await promptDiscordAccountLink({ + provider: resolved.provider, + applicationId: resolved.applicationId, + channel, + discordUserId: reaction.user_id, + replyToMessageId: reaction.message_id, + }); + return { ok: true, ignored: 'discord_reactor_not_linked' }; + } + if (session.userId !== senderUserId) { + return { ok: true, ignored: 'discord_fast_session_user_mismatch' }; + } + + const author = reaction.member?.user ?? { + id: reaction.user_id, + username: `Discord user ${reaction.user_id}`, + }; + const senderDisplayName = + (typeof reaction.member?.nick === 'string' + ? reaction.member.nick + : undefined) ?? + (typeof author.global_name === 'string' + ? author.global_name + : undefined) ?? + author.username; + const reactionInput: FastAgentReactionExternalInput = { + type: 'reaction_added', + provider: 'discord', + reactions: [ + { + name: reaction.emoji.name, + ...(reaction.emoji.id ? { id: reaction.emoji.id } : {}), + }, + ], + reactor: { + externalUserId: reaction.user_id, + ...(senderDisplayName ? { displayName: senderDisplayName } : {}), + }, + message: { + workspaceId: channel.guildId ?? 'dm', + channelId: metadata.communicationChannelId, + messageId: reaction.message_id, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + }, + eventId: event.eventId, + }; + const queued = await queueFastAgentSurfaceReply({ + sessionId: session.id, + userId: senderUserId, + senderDisplayName, + question: buildFastAgentReactionExternalInputQuestion(reactionInput), + currentMessageId: `discord-reaction:${event.eventId}`, + replyToMessageId: reaction.message_id, + externalInput: reactionInput, + }); + return queued + ? { ok: true, fastReactionQueued: true } + : { ok: true, ignored: 'discord_fast_reaction_route_unavailable' }; } const author = reaction.member?.user ?? { diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts index 9fdcc623d..d2bffcc63 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts @@ -14,6 +14,10 @@ const mocks = vi.hoisted(() => ({ vi.mock('@roomote/cloud-agents/server', () => ({ acquireFastAgentTurnLock: mocks.acquireLock, answerFastAgentQuestion: mocks.answerQuestion, + buildFastAgentReactionExternalInputQuestion: vi.fn( + (input: unknown) => + `${JSON.stringify(input)}`, + ), getActiveFastAgentTasks: mocks.getActiveTasks, })); @@ -23,7 +27,7 @@ vi.mock('@roomote/communication', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ - findFastAgentSessionForProviderReply: mocks.findSession, + findFastAgentSessionForProviderMessage: mocks.findSession, recordFastAgentConversationMessageBestEffort: mocks.recordProviderMessage, resolveUserMcpServerConfigs: vi.fn(async () => ({})), })); @@ -102,7 +106,7 @@ describe('Fast Slack reaction input', () => { provider: 'slack', workspaceId: 'T1', channelId: 'C1', - replyToMessageId: '101.000', + messageId: '101.000', userId: 'user-1', }); expect(mocks.answerQuestion).toHaveBeenCalledWith( @@ -117,14 +121,20 @@ describe('Fast Slack reaction input', () => { platformEventTranscriptPayload: { externalInput: expect.objectContaining({ type: 'reaction_added', - emoji: 'eyes', - reactor: { slackUserId: 'UALICE', displayName: '@alice' }, + provider: 'slack', + reactions: [{ name: 'eyes' }], + reactor: { + externalUserId: 'UALICE', + displayName: '@alice', + }, message: expect.objectContaining({ + workspaceId: 'T1', channelId: 'C1', - messageTs: '101.000', - threadTs: '100.000', + messageId: '101.000', + threadId: '100.000', text: 'I found the issue.', }), + eventId: '102.000', }), }, }), diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts index 5e0a35547..0d91efb29 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts @@ -2,14 +2,16 @@ import { Env } from '@roomote/env'; import { acquireFastAgentTurnLock, answerFastAgentQuestion, + buildFastAgentReactionExternalInputQuestion, getActiveFastAgentTasks, + type FastAgentReactionExternalInput, } from '@roomote/cloud-agents/server'; import { buildFastSessionReplyFooterText, resolveFastSessionReplyFooterContext, } from '@roomote/communication'; import { - findFastAgentSessionForProviderReply, + findFastAgentSessionForProviderMessage, recordFastAgentConversationMessageBestEffort, resolveUserMcpServerConfigs, } from '@roomote/sdk/server'; @@ -26,24 +28,11 @@ import type { SlackWebhookContext } from '../context.js'; import { postSlackThreadMarkdownMessage } from '../helpers/thread-posting.js'; import { lookupSlackUserMapping } from '../helpers/user-mapping.js'; -type SlackReactionInput = { - type: 'reaction_added'; - emoji: string; - reactor: { slackUserId: string; displayName?: string }; - message: { - channelId: string; - messageTs: string; - threadTs: string; - text: string; - }; - eventTs: string; -}; - async function processFastAgentReaction(params: { context: SlackWebhookContext; event: SlackReactionAddedEvent; session: NonNullable< - Awaited> + Awaited> >; targetMessage: { text: string; thread_ts?: string }; reactorDisplayName?: string; @@ -67,22 +56,24 @@ async function processFastAgentReaction(params: { ); const threadTs = conversation.replyTarget.threadId; - const reactionInput: SlackReactionInput = { + const reactionInput: FastAgentReactionExternalInput = { type: 'reaction_added', - emoji: event.reaction, + provider: 'slack', + reactions: [{ name: event.reaction }], reactor: { - slackUserId: event.user, + externalUserId: event.user, ...(params.reactorDisplayName ? { displayName: params.reactorDisplayName } : {}), }, message: { + workspaceId: context.teamId, channelId: event.item.channel, - messageTs: event.item.ts, - threadTs, + messageId: event.item.ts, + threadId: threadTs, text: params.targetMessage.text, }, - eventTs: event.event_ts, + eventId: event.event_ts, }; try { @@ -93,7 +84,7 @@ async function processFastAgentReaction(params: { let didSendVisibleResponse = false; const responseText = await answerFastAgentQuestion({ - question: `${JSON.stringify(reactionInput)}`, + question: buildFastAgentReactionExternalInputQuestion(reactionInput), userId: session.userId, conversation, currentMessageId: `slack-reaction:${event.event_ts}`, @@ -239,11 +230,11 @@ export async function maybeRouteFastAgentReaction(params: { }); if (!activeMapping) return false; - const session = await findFastAgentSessionForProviderReply({ + const session = await findFastAgentSessionForProviderMessage({ provider: 'slack', workspaceId: context.teamId, channelId: event.item.channel, - replyToMessageId: event.item.ts, + messageId: event.item.ts, userId: activeMapping.userId, }); if (!session) return false; diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index d278d9559..464b24090 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -40,6 +40,8 @@ const { releaseClaimedOutOfBandMock, callViaEmojiConfigMock, continueFastReplyMock, + queueFastReplyMock, + findFastMessageSessionMock, findFastReplySessionMock, findTeamsConversationRouteMock, getFastSessionMock, @@ -103,6 +105,8 @@ const { releaseClaimedOutOfBandMock: vi.fn(), callViaEmojiConfigMock: vi.fn(), continueFastReplyMock: vi.fn(), + queueFastReplyMock: vi.fn(), + findFastMessageSessionMock: vi.fn(), findFastReplySessionMock: vi.fn(), findTeamsConversationRouteMock: vi.fn(), getFastSessionMock: vi.fn(), @@ -286,12 +290,18 @@ vi.mock('@roomote/sdk/server', () => ({ } : null, ), + findFastAgentSessionForProviderMessage: findFastMessageSessionMock, findFastAgentSessionForProviderReply: findFastReplySessionMock, findTeamsConversationRoute: findTeamsConversationRouteMock, isFastAgentProviderMessage: isFastProviderMessageMock, + queueFastAgentSurfaceReply: queueFastReplyMock, })); vi.mock('@roomote/cloud-agents/server', () => ({ + buildFastAgentReactionExternalInputQuestion: vi.fn( + (input: unknown) => + `${JSON.stringify(input)}`, + ), buildTeamsRoutingContext: buildTeamsRoutingContextMock, enqueueTask: enqueueTaskMock, getOrCreateFastAgentSession: getFastSessionMock, @@ -370,6 +380,8 @@ describe('Teams webhook handler', () => { beforeEach(() => { vi.clearAllMocks(); continueFastReplyMock.mockResolvedValue(true); + queueFastReplyMock.mockResolvedValue(true); + findFastMessageSessionMock.mockResolvedValue(null); findFastReplySessionMock.mockResolvedValue(null); getFastSessionMock.mockResolvedValue({ id: '11111111-1111-4111-8111-111111111111', @@ -501,6 +513,7 @@ describe('Teams webhook handler', () => { threadTs: 'activity-root', }), ); + expect(queueFastReplyMock).not.toHaveBeenCalled(); }); it('launches the exact suggested task when a linked user likes its card', async () => { @@ -556,6 +569,139 @@ describe('Teams webhook handler', () => { }), ); expect(callViaEmojiConfigMock).not.toHaveBeenCalled(); + expect(queueFastReplyMock).not.toHaveBeenCalled(); + }); + + it('queues a native reaction on the owner’s bound Fast message', async () => { + teamsUserMappingFindFirstMock.mockResolvedValue({ + userId: 'mapped-user-1', + }); + findFastMessageSessionMock.mockResolvedValue({ + id: 'fast-session-1', + userId: 'mapped-user-1', + conversation: { + surface: 'teams', + workspaceId: 'tenant-1', + conversationId: '19:conversation@thread.v2:user:mapped-user-1', + replyTarget: { + channelId: '19:conversation@thread.v2', + threadId: 'activity-root', + }, + }, + }); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer valid-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + type: 'messageReaction', + id: 'fast-reaction-1', + text: undefined, + entities: undefined, + replyToId: 'fast-message-1', + reactionsAdded: [{ type: 'heart' }], + }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + fastReactionQueued: true, + }); + expect(findFastMessageSessionMock).toHaveBeenCalledWith({ + provider: 'teams', + workspaceId: 'tenant-1', + channelId: '19:conversation@thread.v2', + messageId: 'fast-message-1', + }); + expect(queueFastReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'fast-session-1', + userId: 'mapped-user-1', + currentMessageId: 'teams-reaction:fast-reaction-1', + replyToMessageId: 'fast-message-1', + externalInput: expect.objectContaining({ + provider: 'teams', + reactions: [{ name: 'heart' }], + }), + }), + ); + }); + + it('rejects a reaction from a different Fast session owner', async () => { + teamsUserMappingFindFirstMock.mockResolvedValue({ + userId: 'mapped-user-1', + }); + findFastMessageSessionMock.mockResolvedValue({ + id: 'fast-session-1', + userId: 'another-user', + conversation: { + surface: 'teams', + workspaceId: 'tenant-1', + conversationId: '19:conversation@thread.v2:user:another-user', + replyTarget: { + channelId: '19:conversation@thread.v2', + threadId: 'activity-root', + }, + }, + }); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer valid-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + type: 'messageReaction', + id: 'fast-reaction-owner-mismatch', + text: undefined, + entities: undefined, + replyToId: 'fast-message-1', + reactionsAdded: [{ type: 'laugh' }], + }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); + expect(queueFastReplyMock).not.toHaveBeenCalled(); + }); + + it('ignores reaction removals', 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-removed', + text: undefined, + entities: undefined, + replyToId: 'fast-message-1', + reactionsAdded: [], + reactionsRemoved: [{ type: 'heart' }], + }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: 'reaction_removed', + }); + expect(findFastMessageSessionMock).not.toHaveBeenCalled(); + expect(queueFastReplyMock).not.toHaveBeenCalled(); }); it('does not claim a reaction suggestion when account mapping fails', async () => { diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index 40ea8a244..188429044 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -29,9 +29,11 @@ import type { TeamsCommunicationProvider } from '@roomote/communication/teams-pr import { continueFastAgentSurfaceReply, createTeamsCommunicationProviderFromRuntimeCredentials, + findFastAgentSessionForProviderMessage, findFastAgentSessionForProviderReply, findTeamsConversationRoute, isFastAgentProviderMessage, + queueFastAgentSurfaceReply, } from '@roomote/sdk/server'; import { exchangeMicrosoftDelegatedGraphToken, @@ -70,6 +72,7 @@ import { appendAttachmentTextsToPromptText } from '@roomote/cloud-agents'; import { AUDIO_TRANSCRIPTION_MAX_SIZE_BYTES, buildTeamsRoutingContext, + buildFastAgentReactionExternalInputQuestion, enqueueTask, formatAudioAttachmentWarning, formatAudioTranscriptionResult, @@ -78,6 +81,7 @@ import { resolveAudioTranscriptionMimeType, routeTask, transcribeAudioAttachment, + type FastAgentReactionExternalInput, type RoutingWorkspace, } from '@roomote/cloud-agents/server'; @@ -1782,6 +1786,12 @@ teams.post('/', async (c) => { } const reactionTargetMessageId = activity.replyToId?.trim(); + if ( + (activity.reactionsAdded?.length ?? 0) === 0 && + (activity.reactionsRemoved?.length ?? 0) > 0 + ) { + return c.json({ ok: true, ignored: 'reaction_removed' }); + } const hasLikeReaction = (activity.reactionsAdded ?? []).some( (reaction) => reaction.type === 'like', ); @@ -1845,15 +1855,101 @@ teams.post('/', async (c) => { } } - if (!configuration && !claimedSuggestionReaction) { - 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' }); } + if (!configuration && !claimedSuggestionReaction) { + const addedReactions = (activity.reactionsAdded ?? []) + .map((reaction) => reaction.type.trim().toLowerCase()) + .filter(isTeamsNativeReactionType) + .map((name) => ({ name })); + if (addedReactions.length === 0) { + return c.json({ ok: true, ignored: 'reaction_not_configured' }); + } + + const metadata = getTeamsActivityCommunicationMetadata(activity); + const tenantId = metadata.teamsTenantId; + const fastChannelId = getTeamsBaseConversationId( + metadata.communicationChannelId, + ); + const fastSession = tenantId + ? await findFastAgentSessionForProviderMessage({ + provider: 'teams', + workspaceId: tenantId, + channelId: fastChannelId, + messageId: targetMessageId, + }) + : null; + if (!fastSession) { + return c.json({ ok: true, ignored: 'reaction_not_configured' }); + } + + const mappedUserId = await findMappedTeamsUserId(activity); + if (!mappedUserId) { + await postTeamsAccountLinkPrompt({ activity, metadata }); + return c.json({ + ok: true, + queued: false, + reason: 'account_link_required', + }); + } + if (fastSession.userId !== mappedUserId) { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); + } + if (fastSession.conversation.surface !== 'teams') { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_surface_mismatch', + }); + } + + const eventId = activity.id ?? randomUUID(); + const reactionInput: FastAgentReactionExternalInput = { + type: 'reaction_added', + provider: 'teams', + reactions: addedReactions, + reactor: { + externalUserId: activity.from?.id ?? mappedUserId, + ...(activity.from?.name?.trim() + ? { displayName: activity.from.name.trim() } + : {}), + }, + message: { + workspaceId: tenantId!, + channelId: fastChannelId, + messageId: targetMessageId, + ...(fastSession.conversation.replyTarget.threadId + ? { threadId: fastSession.conversation.replyTarget.threadId } + : {}), + }, + eventId, + }; + const queued = await queueFastAgentSurfaceReply({ + sessionId: fastSession.id, + userId: mappedUserId, + senderDisplayName: activity.from?.name?.trim() || null, + question: buildFastAgentReactionExternalInputQuestion(reactionInput), + currentMessageId: `teams-reaction:${eventId}`, + replyToMessageId: targetMessageId, + externalInput: reactionInput, + }); + return c.json( + queued + ? { ok: true, fastReactionQueued: true } + : { + ok: true, + ignored: 'teams_fast_reaction_route_unavailable', + }, + ); + } + const mentionName = activity.recipient?.name?.trim() || PRODUCT_NAME; const mentionText = `${mentionName}`; activity = { diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index 42026865c..a202dc1bd 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -41,6 +41,8 @@ const { telegramMappingsFindFirstMock, appendAccountLinkHelpTextMock, continueFastReplyMock, + queueFastReplyMock, + findFastMessageSessionMock, findFastReplySessionMock, getFastSessionMock, isFastProviderMessageMock, @@ -89,6 +91,8 @@ const { telegramMappingsFindFirstMock: vi.fn(), appendAccountLinkHelpTextMock: vi.fn(async (message: string) => message), continueFastReplyMock: vi.fn(), + queueFastReplyMock: vi.fn(), + findFastMessageSessionMock: vi.fn(), findFastReplySessionMock: vi.fn(), getFastSessionMock: vi.fn(), isFastProviderMessageMock: vi.fn(), @@ -276,8 +280,10 @@ vi.mock('@roomote/sdk/server', () => ({ isTelegramLinkCode: (value: string) => /^link-[A-Za-z0-9_-]{16,}$/.test(value.trim()), findTelegramPrimaryChatId: vi.fn(async () => null), + findFastAgentSessionForProviderMessage: findFastMessageSessionMock, findFastAgentSessionForProviderReply: findFastReplySessionMock, isFastAgentProviderMessage: isFastProviderMessageMock, + queueFastAgentSurfaceReply: queueFastReplyMock, TELEGRAM_PRIMARY_CHAT_ENV_VAR_NAME: 'TELEGRAM_PRIMARY_CHAT_ID', claimPendingPrReviewAction: vi.fn(async () => null), claimPendingPrReviewActionsForThread: vi.fn(async () => []), @@ -305,6 +311,10 @@ vi.mock('../../tasks/task-stop.js', () => ({ })); vi.mock('@roomote/cloud-agents/server', () => ({ + buildFastAgentReactionExternalInputQuestion: vi.fn( + (input: unknown) => + `${JSON.stringify(input)}`, + ), buildTelegramRoutingContext: buildTelegramRoutingContextMock, classifyFollowUp: classifyFollowUpMock, enqueueTask: enqueueTaskMock, @@ -394,6 +404,8 @@ describe('Telegram webhook handler', () => { telegramMappingsFindFirstMock.mockReset(); consumeLinkCodeMock.mockReset(); continueFastReplyMock.mockResolvedValue(true); + queueFastReplyMock.mockResolvedValue(true); + findFastMessageSessionMock.mockResolvedValue(null); findFastReplySessionMock.mockResolvedValue(null); getFastSessionMock.mockRejectedValue(new Error('Fast unavailable')); isFastProviderMessageMock.mockResolvedValue(false); @@ -481,6 +493,114 @@ describe('Telegram webhook handler', () => { postMessageMock.mockResolvedValue({ messageId: 'telegram-response' }); }); + it('queues a new reaction on the owner’s bound Fast message', async () => { + findFastMessageSessionMock.mockResolvedValue({ + id: 'fast-session-1', + userId: 'mapped-user-1', + conversation: { + surface: 'telegram', + workspaceId: '222', + conversationId: '222:user:mapped-user-1', + replyTarget: { channelId: '222' }, + }, + }); + mockTelegramLinkedSender('mapped-user-1'); + + const response = await postTelegramUpdate({ + update_id: 124, + message_reaction: { + chat: { id: 222, type: 'private' }, + message_id: 777, + date: 1_700_000_000, + user: { + id: 111, + first_name: 'Ada', + last_name: 'Lovelace', + username: 'ada', + }, + old_reaction: [], + new_reaction: [{ type: 'emoji', emoji: '❤️' }], + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + fastReactionQueued: true, + }); + expect(findFastMessageSessionMock).toHaveBeenCalledWith({ + provider: 'telegram', + workspaceId: '222', + channelId: '222', + messageId: '777', + }); + expect(queueFastReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'fast-session-1', + userId: 'mapped-user-1', + currentMessageId: 'telegram-reaction:124', + replyToMessageId: '777', + externalInput: expect.objectContaining({ + provider: 'telegram', + reactions: [{ name: '❤️' }], + }), + }), + ); + }); + + it('rejects a reaction from a different Fast session owner', async () => { + findFastMessageSessionMock.mockResolvedValue({ + id: 'fast-session-1', + userId: 'another-user', + conversation: { + surface: 'telegram', + workspaceId: '222', + conversationId: '222:user:another-user', + replyTarget: { channelId: '222' }, + }, + }); + mockTelegramLinkedSender('mapped-user-1'); + + const response = await postTelegramUpdate({ + update_id: 125, + message_reaction: { + chat: { id: 222, type: 'private' }, + message_id: 777, + date: 1_700_000_000, + user: { id: 111, first_name: 'Ada' }, + old_reaction: [], + new_reaction: [{ type: 'emoji', emoji: '🔥' }], + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); + expect(queueFastReplyMock).not.toHaveBeenCalled(); + }); + + it('ignores reaction removals without starting Fast or a suggestion', async () => { + const response = await postTelegramUpdate({ + update_id: 126, + message_reaction: { + chat: { id: 222, type: 'private' }, + message_id: 777, + date: 1_700_000_000, + user: { id: 111, first_name: 'Ada' }, + old_reaction: [{ type: 'emoji', emoji: '👍' }], + new_reaction: [], + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: 'reaction_removed_or_unchanged', + }); + expect(findFastMessageSessionMock).not.toHaveBeenCalled(); + expect(queueFastReplyMock).not.toHaveBeenCalled(); + }); + it('remembers implicit Telegram topics so the task title can replace New Chat', async () => { const response = await postTelegramUpdate( createTelegramUpdate({ diff --git a/apps/api/src/handlers/telegram/index.ts b/apps/api/src/handlers/telegram/index.ts index 54c3767b1..4e80259f0 100644 --- a/apps/api/src/handlers/telegram/index.ts +++ b/apps/api/src/handlers/telegram/index.ts @@ -17,6 +17,7 @@ import { getTelegramUpdateCommunicationMetadata, getTelegramUpdateMessage, getTelegramUpdateMessageReaction, + getNewTelegramMessageReactions, getTelegramNewTaskCommand, isTelegramImplicitTopicCreatedMessage, isTelegramPrivateChat, @@ -38,12 +39,18 @@ import { retireTelegramPrReviewOffersBestEffort } from './pr-review-action.js'; import { continueFastAgentSurfaceReply, consumeTelegramLinkCode, + findFastAgentSessionForProviderMessage, findFastAgentSessionForProviderReply, isTelegramLinkCode, isFastAgentProviderMessage, + queueFastAgentSurfaceReply, restoreTelegramLinkCode, } from '@roomote/sdk/server'; -import { getOrCreateFastAgentSession } from '@roomote/cloud-agents/server'; +import { + buildFastAgentReactionExternalInputQuestion, + getOrCreateFastAgentSession, + type FastAgentReactionExternalInput, +} from '@roomote/cloud-agents/server'; import { handleTelegramCallbackQuery, @@ -139,15 +146,98 @@ telegram.post('/', async (c) => { if (!claimedReaction) { return c.json({ ok: true, duplicate: true }); } - if (!isNewTelegramThumbsUpReaction(messageReaction)) { - return c.json({ ok: true, ignored: 'unsupported_reaction' }); + const addedReactions = getNewTelegramMessageReactions(messageReaction); + if (addedReactions.length === 0) { + return c.json({ ok: true, ignored: 'reaction_removed_or_unchanged' }); + } + + if (isNewTelegramThumbsUpReaction(messageReaction)) { + const handled = await handleTelegramSuggestionReaction(messageReaction); + if (handled) { + return c.json({ ok: true, suggestionStarted: true }); + } + } + + const chatId = String(messageReaction.chat.id); + const messageId = String(messageReaction.message_id); + const threadId = messageReaction.message_thread_id + ? String(messageReaction.message_thread_id) + : undefined; + const fastSession = await findFastAgentSessionForProviderMessage({ + provider: 'telegram', + workspaceId: chatId, + channelId: chatId, + ...(threadId ? { threadId } : {}), + messageId, + }); + if (!fastSession) { + return c.json({ ok: true, ignored: 'reaction_target_not_fast' }); + } + if (!messageReaction.user) { + return c.json({ ok: true, ignored: 'reaction_user_missing' }); } - const handled = await handleTelegramSuggestionReaction(messageReaction); + const senderUserId = await resolveTelegramSenderUserId( + String(messageReaction.user.id), + ); + if (!senderUserId) { + await postTelegramMessageBestEffort({ + chatId, + ...(threadId ? { threadId } : {}), + replyToMessageId: messageId, + text: 'Link your Roomote account to respond to Roomote from Telegram.', + }); + return c.json({ + ok: true, + queued: false, + reason: 'telegram_reactor_not_linked', + }); + } + if (fastSession.userId !== senderUserId) { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); + } + + const senderDisplayName = + [messageReaction.user.first_name, messageReaction.user.last_name] + .filter(Boolean) + .join(' ') + .trim() || + messageReaction.user.username?.trim() || + null; + const eventId = String(update.update_id); + const reactionInput: FastAgentReactionExternalInput = { + type: 'reaction_added', + provider: 'telegram', + reactions: addedReactions, + reactor: { + externalUserId: String(messageReaction.user.id), + ...(senderDisplayName ? { displayName: senderDisplayName } : {}), + }, + message: { + workspaceId: chatId, + channelId: chatId, + messageId, + ...(threadId ? { threadId } : {}), + }, + eventId, + }; + const queued = await queueFastAgentSurfaceReply({ + sessionId: fastSession.id, + userId: senderUserId, + senderDisplayName, + question: buildFastAgentReactionExternalInputQuestion(reactionInput), + currentMessageId: `telegram-reaction:${eventId}`, + replyToMessageId: messageId, + externalInput: reactionInput, + }); return c.json( - handled - ? { ok: true, suggestionStarted: true } - : { ok: true, ignored: 'reaction_target_not_suggestion' }, + queued + ? { ok: true, fastReactionQueued: true } + : { ok: true, ignored: 'telegram_fast_reaction_route_unavailable' }, ); } diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 233661c11..951be1dd5 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -220,6 +220,11 @@ Provider support differs slightly: `sad`, and `angry` reactions; choose an equivalent configured emoji such as `:thumbsup:` for Like or `:heart:` for Heart. +Telegram reactions on Roomote Fast replies are supported, but Telegram is not +available for **Call Roomote via emoji** on arbitrary messages. Its Bot API +reaction updates are handled only for user-attributed reactions on Roomote +replies and suggested-task cards. + ## Channel automations The channel section starts with **Auto-respond to channels**. diff --git a/apps/docs/communications.mdx b/apps/docs/communications.mdx index e854be4fa..106be5fb5 100644 --- a/apps/docs/communications.mdx +++ b/apps/docs/communications.mdx @@ -58,6 +58,26 @@ also needs an environment that can run the suggestion's target repository. Configured **Call Roomote via emoji** automations are separate from these suggestion reactions. +## React to Roomote replies + +When you react to a Roomote reply in an active Fast conversation, Roomote can +use that reaction as context. The linked user who owns the Fast conversation +must add the reaction. Roomote posts a text reply when the reaction warrants a +response and otherwise stays silent; it does not react to the reaction event +itself. + +| Provider | Fast reply reactions | Provider limits | +| --- | --- | --- | +| Slack | Supported | Slack sends standard and workspace custom `reaction_added` events. | +| Discord | Supported | Discord Gateway sends standard and server custom reaction-add events, but the event does not include the reacted-to message text. | +| Microsoft Teams | Supported | Bot Framework sends native `messageReaction` activities only for messages posted by Roomote. Reaction removals are ignored. | +| Telegram | Supported | Roomote uses user-attributed `message_reaction` updates. Anonymous aggregate `message_reaction_count` updates are not supported because they do not identify the reacting user. | + +These reaction paths apply only to Roomote replies that were recorded for the +current Fast conversation. Reactions on older or unrelated messages do not gain +access to that conversation. Suggested-task reactions keep their dedicated +launch behavior described above. + Use the same stable public URL for every provider app setting that requires a callback or webhook. If you change that URL, update those provider settings and restart Roomote with the matching deployment URL. Discord receives messages diff --git a/apps/docs/providers/communications/telegram.mdx b/apps/docs/providers/communications/telegram.mdx index 46ad19a17..8aa5b90a7 100644 --- a/apps/docs/providers/communications/telegram.mdx +++ b/apps/docs/providers/communications/telegram.mdx @@ -67,7 +67,11 @@ When you save Telegram credentials in Roomote, the webhook is registered automatically at `/api/webhooks/telegram` with the managed secret token and `allowed_updates` including `message`, `callback_query`, and `message_reaction`. Roomote uses newly added 👍 reactions on suggested-task -messages to launch the selected task. +messages to launch the selected task and user-attributed reactions on Roomote +Fast replies as conversation input. Roomote does not request +`message_reaction_count`: those aggregate updates do not identify the reacting +user, so they cannot satisfy Roomote's account and conversation ownership +checks. If the connection check reports a mismatch, delivery error, or stale update configuration, use **Repair** in Telegram settings to re-register it. diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts index 91667e4b0..1eed4bd53 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts @@ -504,7 +504,7 @@ describe('buildFastAgentSystemPrompt', () => { expect(prompt).not.toContain('Do not call "ignore_event"'); expect(prompt).toContain('Do not use the reaction tool'); expect(prompt).toContain( - 'an inbound Slack reaction event is not itself a reactable message surface', + 'an inbound emoji-reaction event is not itself a reactable message surface', ); expect(prompt).toContain( 'If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above', diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index f32fc51da..5048f6729 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -2485,6 +2485,40 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { expect(adapter.postReply).not.toHaveBeenCalled(); }); + it('rejects reaction side effects during platform events', async () => { + let reactionResult: unknown; + mocks.generateText.mockImplementation( + async (_params, _session, options) => { + await options.onSessionReady('opencode-session-1'); + reactionResult = await invokeTool(nativeToolNames.sendChatReaction, { + name: 'thumbsup', + purpose: 'closeout', + }); + await invokeTool(nativeToolNames.ignoreEvent, { + reason: 'no response needed', + }); + return ''; + }, + ); + const adapter = callbacks(); + + await answerFastAgentQuestion({ + ...baseParams, + currentMessageId: 'slack-reaction:1710000000.000100', + turnSource: 'platform_event', + platformEventKind: 'external_input', + platformEventVisibility: 'optional', + adapter, + }); + + expect(reactionResult).toEqual({ + success: false, + error: + 'Emoji reactions are unavailable during platform events. Use send_chat_reply or ignore_event instead.', + }); + expect(adapter.postReaction).not.toHaveBeenCalled(); + }); + it('posts a closeout when a visibility-required event tries to ignore itself', async () => { mocks.generateText.mockImplementation( async (_params, _session, options) => { diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts index dac627c94..1928e277f 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts @@ -27,6 +27,27 @@ export type FastAgentPlatformEventKind = | 'automation' | 'external_input'; +export type FastAgentReactionExternalInput = { + type: 'reaction_added'; + provider: 'slack' | 'discord' | 'teams' | 'telegram'; + reactions: Array<{ name: string; id?: string }>; + reactor: { externalUserId: string; displayName?: string }; + message: { + workspaceId: string; + channelId: string; + messageId: string; + threadId?: string; + text?: string; + }; + eventId: string; +}; + +export function buildFastAgentReactionExternalInputQuestion( + input: FastAgentReactionExternalInput, +): string { + return `${JSON.stringify(input)}`; +} + export type FastAgentSuggestedTask = { title: string; brief: string; diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index 64e91011e..caa08ef9a 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts @@ -257,7 +257,7 @@ ${ : '- No failed-start retry tool is available for this event. Report or ignore it without retrying.' } - Launching creates a separate delegated task; it does not retry the task associated with this event. -- Do not use the reaction tool because a platform event has no incoming chat message to react to. In particular, an inbound Slack reaction event is not itself a reactable message surface. If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above. +- Do not use the reaction tool because a platform event has no incoming chat message to react to. In particular, an inbound emoji-reaction event is not itself a reactable message surface. If the reaction warrants a response, post a text reply; otherwise stay silent according to the ignore rules above. ${ platformEventKind === 'automation' ? `- Execute the automation prompt now. Use integrations directly when sufficient, and launch a task only when repository or workspace execution is actually required. The configured model is a delegated-task default, not the Fast inference model. diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index 8489df7a2..ff3c5456e 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -1577,6 +1577,13 @@ export async function answerFastAgentQuestion({ case FAST_AGENT_NATIVE_TOOL_NAMES.sendChatReaction: { const args = chatReactionArgsSchema.parse(call.args); + if (platformEvent) { + return { + success: false, + error: + 'Emoji reactions are unavailable during platform events. Use send_chat_reply or ignore_event instead.', + }; + } if (!adapter.postReaction) { return { success: false, diff --git a/packages/communication/src/__tests__/teams-activity.test.ts b/packages/communication/src/__tests__/teams-activity.test.ts index 11fd4b8d1..c84d57da5 100644 --- a/packages/communication/src/__tests__/teams-activity.test.ts +++ b/packages/communication/src/__tests__/teams-activity.test.ts @@ -16,6 +16,23 @@ import { } from '../teams-activity'; describe('Teams activity helpers', () => { + it('parses added and removed message reactions', () => { + const parsed = parseTeamsActivity({ + type: 'messageReaction', + id: 'reaction-1', + conversation: { id: '19:conversation@thread.v2' }, + replyToId: 'activity-root', + reactionsAdded: [{ type: 'heart' }], + reactionsRemoved: [{ type: 'like' }], + }); + + expect(parsed.success).toBe(true); + expect(parsed.data).toMatchObject({ + reactionsAdded: [{ type: 'heart' }], + reactionsRemoved: [{ type: 'like' }], + }); + }); + it('parses Teams message activities into queued communication messages', () => { const parsed = parseTeamsActivity({ type: 'message', diff --git a/packages/communication/src/__tests__/telegram-update.test.ts b/packages/communication/src/__tests__/telegram-update.test.ts index 4bb6bf0ba..12f30178f 100644 --- a/packages/communication/src/__tests__/telegram-update.test.ts +++ b/packages/communication/src/__tests__/telegram-update.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getNewTelegramMessageReactions, getTelegramNewTaskCommand, getTelegramUpdateCallbackQuery, getTelegramUpdateCommunicationMetadata, @@ -92,6 +93,20 @@ describe('Telegram update helpers', () => { old_reaction: [{ type: 'emoji', emoji: '👍' }], }), ).toBe(false); + expect(reaction && getNewTelegramMessageReactions(reaction)).toEqual([ + { name: '👍' }, + ]); + expect( + reaction && + getNewTelegramMessageReactions({ + ...reaction, + old_reaction: [{ type: 'emoji', emoji: '👍' }], + new_reaction: [ + { type: 'emoji', emoji: '👍' }, + { type: 'custom_emoji', custom_emoji_id: 'custom-1' }, + ], + }), + ).toEqual([{ name: 'custom_emoji:custom-1', id: 'custom-1' }]); }); it('parses Telegram messages into queued communication messages', () => { diff --git a/packages/communication/src/teams-activity.ts b/packages/communication/src/teams-activity.ts index b0f93555d..3cbdc81be 100644 --- a/packages/communication/src/teams-activity.ts +++ b/packages/communication/src/teams-activity.ts @@ -80,6 +80,15 @@ export const teamsActivitySchema = z .passthrough(), ) .optional(), + reactionsRemoved: z + .array( + z + .object({ + type: z.string(), + }) + .passthrough(), + ) + .optional(), attachments: z.array(z.unknown()).optional(), }) .passthrough(); diff --git a/packages/communication/src/telegram-update.ts b/packages/communication/src/telegram-update.ts index 19f2e0965..26aedb1f9 100644 --- a/packages/communication/src/telegram-update.ts +++ b/packages/communication/src/telegram-update.ts @@ -222,6 +222,27 @@ export function isNewTelegramThumbsUpReaction( ); } +export function getNewTelegramMessageReactions( + reaction: TelegramMessageReaction, +): Array<{ name: string; id?: string }> { + const reactionKey = (item: TelegramMessageReaction['new_reaction'][number]) => + `${item.type}:${item.emoji ?? ''}:${item.custom_emoji_id ?? ''}`; + const oldReactionKeys = new Set(reaction.old_reaction.map(reactionKey)); + + return reaction.new_reaction + .filter((item) => !oldReactionKeys.has(reactionKey(item))) + .map((item) => + item.type === 'emoji' && item.emoji + ? { name: item.emoji } + : item.type === 'custom_emoji' && item.custom_emoji_id + ? { + name: `custom_emoji:${item.custom_emoji_id}`, + id: item.custom_emoji_id, + } + : { name: item.type }, + ); +} + export function getTelegramChatId(message: TelegramMessage): string { return String(message.chat.id); } diff --git a/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts b/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts index 12967713d..ac18be69a 100644 --- a/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-provider-message.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { db, fastAgentConversations, userFactory } from '@roomote/db/server'; import { + findFastAgentSessionForProviderMessage, findFastAgentSessionForProviderReply, isFastAgentProviderMessage, recordFastAgentProviderMessage, @@ -50,14 +51,66 @@ describe('Fast provider message bindings', () => { }); await expect( - findFastAgentSessionForProviderReply({ + findFastAgentSessionForProviderMessage({ provider: 'slack', workspaceId: `team:${suffix}`, channelId: `channel:${suffix}`, - replyToMessageId: `message:${suffix}`, + threadId: `thread:${suffix}`, + messageId: `message:${suffix}`, + userId: user.id, + }), + ).resolves.toMatchObject({ id: conversation.id, userId: user.id }); + }); + + it('requires exact provider-message route and ownership for reactions', async () => { + const suffix = crypto.randomUUID(); + const { user, conversation } = await createFastConversation({ + surface: 'telegram', + workspaceId: `chat:${suffix}`, + conversationId: `topic:${suffix}`, + channelId: `chat:${suffix}`, + threadId: `topic:${suffix}`, + }); + const otherUser = await userFactory.create(); + await recordFastAgentProviderMessage({ + sessionId: conversation.id, + provider: 'telegram', + workspaceId: `chat:${suffix}`, + channelId: `chat:${suffix}`, + threadId: `topic:${suffix}`, + messageId: `message:${suffix}`, + }); + + await expect( + findFastAgentSessionForProviderMessage({ + provider: 'telegram', + workspaceId: `chat:${suffix}`, + channelId: `chat:${suffix}`, + threadId: `topic:${suffix}`, + messageId: `message:${suffix}`, userId: user.id, }), ).resolves.toMatchObject({ id: conversation.id, userId: user.id }); + await expect( + findFastAgentSessionForProviderMessage({ + provider: 'telegram', + workspaceId: `chat:${suffix}`, + channelId: `chat:${suffix}`, + threadId: `other-topic:${suffix}`, + messageId: `message:${suffix}`, + userId: user.id, + }), + ).resolves.toBeNull(); + await expect( + findFastAgentSessionForProviderMessage({ + provider: 'telegram', + workspaceId: `chat:${suffix}`, + channelId: `chat:${suffix}`, + threadId: `topic:${suffix}`, + messageId: `message:${suffix}`, + userId: otherUser.id, + }), + ).resolves.toBeNull(); }); it('resolves a Discord DM reply to the bound Fast session', async () => { diff --git a/packages/sdk/src/server/lib/fast-agent-provider-message.ts b/packages/sdk/src/server/lib/fast-agent-provider-message.ts index 20a236383..28c0fe9b5 100644 --- a/packages/sdk/src/server/lib/fast-agent-provider-message.ts +++ b/packages/sdk/src/server/lib/fast-agent-provider-message.ts @@ -153,6 +153,38 @@ export async function findFastAgentSessionForProviderReply( : null; } +export async function findFastAgentSessionForProviderMessage( + input: ProviderRoute & { messageId: string; userId?: string }, +): Promise { + const binding = await db.query.fastAgentProviderMessages.findFirst({ + where: and( + eq(fastAgentProviderMessages.provider, input.provider), + eq(fastAgentProviderMessages.workspaceId, input.workspaceId), + eq(fastAgentProviderMessages.channelId, input.channelId), + eq(fastAgentProviderMessages.messageId, input.messageId), + ), + columns: { conversationId: true, threadId: true }, + }); + if ( + !binding || + (input.threadId !== undefined && binding.threadId !== input.threadId) + ) { + return null; + } + + const session = await fastAgentConversationRepository.findById({ + id: binding.conversationId, + }); + return session && + (!input.userId || session.userId === input.userId) && + matchesProviderRoute(session, { + ...input, + ...(binding.threadId ? { threadId: binding.threadId } : {}), + }) + ? session + : null; +} + export async function isFastAgentProviderMessage(input: { provider: FastAgentReplyProvider; messageId: string; diff --git a/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts b/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts index 8a33de86f..5a7d0979a 100644 --- a/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts @@ -6,6 +6,22 @@ const mocks = vi.hoisted(() => ({ telegramPostMessage: vi.fn(), telegramEditMessage: vi.fn(), findTeamsConversationRoute: vi.fn(), + slackPostThreadMessage: vi.fn(), + slackUpdateMessage: vi.fn(), +})); + +vi.mock('@roomote/slack', () => ({ + buildSlackThreadReplyFooterBlock: vi.fn(() => ({ type: 'context' })), + createFastAgentSlackLiveTaskLauncher: vi.fn(() => vi.fn()), + getSlackThreadReplyFooterMessageTs: vi.fn(async () => null), + postSlackThreadMessageWithFooterText: mocks.slackPostThreadMessage, + withSlackThreadReplyFooterLock: vi.fn( + async ({ fn }: { fn: () => Promise }) => fn(), + ), + ROOMOTE_THREAD_REPLY_QUOTE_BLOCK_ID: 'quote', + SlackNotifier: vi.fn(function () { + return { updateMessage: mocks.slackUpdateMessage }; + }), })); vi.mock('./teams-communication', () => ({ @@ -29,6 +45,7 @@ import { fastAgentConversations, fastAgentProviderMessages, fastAgentMessages, + slackInstallations, userFactory, } from '@roomote/db/server'; @@ -80,6 +97,8 @@ describe('buildFastAgentSurfaceReplyDelivery', () => { serviceUrl: 'https://smba.example.com/amer/', workspaceId: 'tenant-1', }); + mocks.slackPostThreadMessage.mockResolvedValue('slack-message-1'); + mocks.slackUpdateMessage.mockResolvedValue(true); }); it('serves web sessions with a transcript-only adapter', async () => { @@ -178,6 +197,57 @@ describe('buildFastAgentSurfaceReplyDelivery', () => { ).resolves.toBeNull(); }); + it('binds Slack surface replies and replacements to the Fast session', async () => { + const user = await userFactory.create(); + const conversation = await createConversation({ + userId: user.id, + surface: 'slack', + replyTarget: { channelId: 'C456', threadId: '1700000000.000200' }, + }); + await db.insert(slackInstallations).values({ + teamId: conversation.workspaceId, + teamName: 'Test workspace', + appId: 'A123', + botUserId: 'B123', + botAccessToken: 'xoxb-test', + scopes: { bot: ['chat:write'] }, + installedByUserId: user.id, + isActive: true, + }); + + const delivery = await buildFastAgentSurfaceReplyDelivery({ + sessionId: conversation.id, + userId: user.id, + senderDisplayName: 'Matt', + question: 'Follow up', + }); + const handle = await delivery!.adapter.postReply({ + purpose: 'closeout', + message: 'First reply', + }); + await delivery!.adapter.replaceReply!(handle!, { + purpose: 'closeout', + message: 'Updated reply', + }); + + await expect( + db.query.fastAgentProviderMessages.findFirst({ + where: and( + eq(fastAgentProviderMessages.provider, 'slack'), + eq(fastAgentProviderMessages.conversationId, conversation.id), + eq(fastAgentProviderMessages.messageId, 'slack-message-1'), + ), + }), + ).resolves.toMatchObject({ + workspaceId: conversation.workspaceId, + channelId: 'C456', + threadId: '1700000000.000200', + }); + expect(mocks.slackUpdateMessage).toHaveBeenCalledWith( + expect.objectContaining({ ts: 'slack-message-1' }), + ); + }); + it('returns null for an unknown session', async () => { const user = await userFactory.create(); await expect( @@ -276,4 +346,51 @@ describe('buildFastAgentSurfaceReplyDelivery', () => { ); }, ); + + it('keeps a Telegram reaction event id separate from its reply target', async () => { + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'telegram', + workspaceId: 'telegram-chat-reaction', + conversationId: `telegram-reaction-${Date.now()}`, + currentReplyChannelId: 'telegram-chat-reaction', + }) + .returning(); + + const delivery = await buildFastAgentSurfaceReplyDelivery({ + sessionId: conversation!.id, + userId: user.id, + senderDisplayName: 'Matt', + question: '{}', + currentMessageId: 'telegram-reaction:123', + replyToMessageId: '777', + externalInput: { + type: 'reaction_added', + provider: 'telegram', + reactions: [{ name: '👍' }], + reactor: { externalUserId: '111', displayName: 'Matt' }, + message: { + workspaceId: 'telegram-chat-reaction', + channelId: 'telegram-chat-reaction', + messageId: '777', + }, + eventId: '123', + }, + }); + + await delivery!.adapter.postReply({ + purpose: 'closeout', + message: 'Thanks for confirming.', + }); + + expect(mocks.telegramPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: '777', + text: expect.not.stringContaining('external_input'), + }), + ); + }); }); diff --git a/packages/sdk/src/server/lib/fast-agent-surface-reply.ts b/packages/sdk/src/server/lib/fast-agent-surface-reply.ts index dabba5169..0df419b59 100644 --- a/packages/sdk/src/server/lib/fast-agent-surface-reply.ts +++ b/packages/sdk/src/server/lib/fast-agent-surface-reply.ts @@ -6,6 +6,7 @@ import { getActiveFastAgentTasks, resolveApiBaseUrl, type FastAgentConversation, + type FastAgentReactionExternalInput, type FastAgentTurnAdapter, } from '@roomote/cloud-agents/server'; import { @@ -114,6 +115,17 @@ export type FastAgentSurfaceReplyDelivery = { >; }; +type FastAgentSurfaceReplyParams = { + sessionId: string; + userId: string; + senderDisplayName: string | null; + question: string; + currentMessageId: string; + replyToMessageId?: string; + images?: string[]; + externalInput?: FastAgentReactionExternalInput; +}; + export async function canUserAccessFastAgentSession(params: { sessionId: string; userId: string; @@ -151,6 +163,8 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { senderDisplayName: string | null; question: string; currentMessageId?: string; + replyToMessageId?: string; + externalInput?: FastAgentReactionExternalInput; }): Promise { const session = await fastAgentConversationRepository.findById({ id: params.sessionId, @@ -202,10 +216,12 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { } const slack = new SlackNotifier(installation.botAccessToken); - let pendingQuote = buildSlackReplyQuote({ - senderDisplayName: params.senderDisplayName, - text: params.question, - }); + let pendingQuote = params.externalInput + ? null + : buildSlackReplyQuote({ + senderDisplayName: params.senderDisplayName, + text: params.question, + }); return { conversation, @@ -249,6 +265,11 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { if (!messageTs) { throw new Error('Slack did not return a Fast reply timestamp.'); } + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId: messageTs, + }); return { messageId: messageTs }; }, replaceReply: async (handle, { message }) => { @@ -289,6 +310,11 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { if (!updated) { throw new Error('Slack did not update the Fast reply.'); } + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId: handle.messageId, + }); return handle; }, }, @@ -302,10 +328,12 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { return null; } - let pendingQuote = buildDiscordReplyQuote({ - senderDisplayName: params.senderDisplayName, - text: params.question, - }); + let pendingQuote = params.externalInput + ? null + : buildDiscordReplyQuote({ + senderDisplayName: params.senderDisplayName, + text: params.question, + }); return { conversation, @@ -437,6 +465,7 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { if (!provider) { return null; } + const replyToMessageId = params.replyToMessageId ?? params.currentMessageId; return { conversation, adapter: { @@ -450,9 +479,7 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { ...(conversation.replyTarget.threadId ? { threadId: conversation.replyTarget.threadId } : {}), - ...(params.currentMessageId - ? { replyToMessageId: params.currentMessageId } - : {}), + ...(replyToMessageId ? { replyToMessageId } : {}), text: `${message}\n\n${buildFastSessionReplyFooterText({ provider: 'telegram', sessionId: session.id, ...footerContext })}`, textFormat: 'markdown', }); @@ -484,14 +511,9 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { return null; } -export async function continueFastAgentSurfaceReply(params: { - sessionId: string; - userId: string; - senderDisplayName: string | null; - question: string; - currentMessageId: string; - images?: string[]; -}): Promise { +export async function continueFastAgentSurfaceReply( + params: FastAgentSurfaceReplyParams, +): Promise { const delivery = await buildFastAgentSurfaceReplyDelivery(params); if (!delivery) { return false; @@ -500,15 +522,11 @@ export async function continueFastAgentSurfaceReply(params: { return runFastAgentSurfaceReply({ ...params, delivery }); } -async function runFastAgentSurfaceReply(params: { - sessionId: string; - userId: string; - senderDisplayName: string | null; - question: string; - currentMessageId: string; - images?: string[]; - delivery: FastAgentSurfaceReplyDelivery; -}): Promise { +async function runFastAgentSurfaceReply( + params: FastAgentSurfaceReplyParams & { + delivery: FastAgentSurfaceReplyDelivery; + }, +): Promise { const { delivery } = params; const release = await acquireFastAgentTurnLock({ @@ -520,6 +538,9 @@ async function runFastAgentSurfaceReply(params: { const apiBaseUrl = resolveApiBaseUrl() ?? undefined; try { + const activeTasks = params.externalInput + ? await getActiveFastAgentTasks(params.sessionId) + : undefined; await answerFastAgentQuestion({ question: params.question, images: params.images, @@ -529,6 +550,18 @@ async function runFastAgentSurfaceReply(params: { currentMessageId: params.currentMessageId, signal: release.signal, senderDisplayName: params.senderDisplayName ?? undefined, + ...(activeTasks ? { activeTasks } : {}), + ...(params.externalInput + ? { + senderExternalId: params.externalInput.reactor.externalUserId, + turnSource: 'platform_event' as const, + platformEventKind: 'external_input' as const, + platformEventVisibility: 'optional' as const, + platformEventTranscriptPayload: { + externalInput: params.externalInput, + }, + } + : {}), adapter: { resolveMcpServerConfigs: () => resolveUserMcpServerConfigs({ @@ -545,14 +578,9 @@ async function runFastAgentSurfaceReply(params: { } } -export async function queueFastAgentSurfaceReply(params: { - sessionId: string; - userId: string; - senderDisplayName: string | null; - question: string; - currentMessageId: string; - images?: string[]; -}): Promise { +export async function queueFastAgentSurfaceReply( + params: FastAgentSurfaceReplyParams, +): Promise { const delivery = await buildFastAgentSurfaceReplyDelivery(params); if (!delivery) return false;