diff --git a/CHANGELOG.md b/CHANGELOG.md index fefa84980..6eb4023a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.33.0 (2026-08-04) + +This release adds new ways to connect and invoke Roomote, gives deployments clearer account-linking guidance, and improves automation and Amazon Bedrock model discovery. + +### Highlights + +- Start Roomote from existing Slack, Discord, and Microsoft Teams conversations with an administrator-configured emoji reaction. +- Connect Resend with safe inspection-oriented defaults and explicit controls for sensitive actions. +- Add deployment-specific account-linking guidance across chat, source control, and sign-in surfaces. +- Keep Amazon Bedrock models visible and organized under one provider section in model settings. + +### Minor changes + +- Let administrators configure an emoji that starts Roomote from an existing Slack, Discord, or Microsoft Teams conversation, reusing the normal task flow and preserving the reacting user's account attribution. +- Add Resend as a deployment-wide integration with inspection-oriented access by default and explicit administrator controls for sensitive email, credential, automation, contact, domain, and webhook actions. +- Let administrators add deployment-specific account-linking guidance that appears alongside Roomote's built-in instructions in source-control comments, Discord, Telegram, and the sign-in page. + +### Patch changes + +- Keep Amazon Bedrock Mantle models visible after settings reload and group native Bedrock and Mantle entries under one Amazon Bedrock section instead of showing duplicate headings. +- Make routine Discord release announcements shorter and suppress link-preview cards while keeping the release notes link available. +- Make automations the highest-priority onboarding prompt for users who have not enabled one, and link the Automations page directly to practical Cookbook recipes. + ## 0.32.1 (2026-08-04) This patch restores licensed Cloud seat limits and keeps custom automation destinations limited to connected communication providers. diff --git a/apps/api/src/handlers/account-link-help.test.ts b/apps/api/src/handlers/account-link-help.test.ts new file mode 100644 index 000000000..4a4533979 --- /dev/null +++ b/apps/api/src/handlers/account-link-help.test.ts @@ -0,0 +1,41 @@ +const { getHelpTextMock, warnMock } = vi.hoisted(() => ({ + getHelpTextMock: vi.fn(), + warnMock: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + getDeploymentAccountLinkHelpText: getHelpTextMock, +})); + +vi.mock('../logging.js', () => ({ + apiLogger: { warn: warnMock }, +})); + +import { appendAccountLinkHelpText } from './account-link-help'; + +describe('appendAccountLinkHelpText', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('appends configured deployment help', async () => { + getHelpTextMock.mockResolvedValue('Ask an admin for an invite.'); + + await expect(appendAccountLinkHelpText('Link your account.')).resolves.toBe( + 'Link your account. Ask an admin for an invite.', + ); + }); + + it('preserves the base message when help is unset or unavailable', async () => { + getHelpTextMock.mockResolvedValueOnce(null); + await expect(appendAccountLinkHelpText('Link your account.')).resolves.toBe( + 'Link your account.', + ); + + getHelpTextMock.mockRejectedValueOnce(new Error('database unavailable')); + await expect(appendAccountLinkHelpText('Link your account.')).resolves.toBe( + 'Link your account.', + ); + expect(warnMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/api/src/handlers/account-link-help.ts b/apps/api/src/handlers/account-link-help.ts new file mode 100644 index 000000000..83b7ff315 --- /dev/null +++ b/apps/api/src/handlers/account-link-help.ts @@ -0,0 +1,17 @@ +import { getDeploymentAccountLinkHelpText } from '@roomote/db/server'; + +import { apiLogger } from '../logging.js'; + +export async function appendAccountLinkHelpText( + baseMessage: string, +): Promise { + try { + const helpText = await getDeploymentAccountLinkHelpText(); + return helpText ? `${baseMessage} ${helpText}` : baseMessage; + } catch (error) { + apiLogger.warn( + `[account-link] Failed to load deployment help text: ${error instanceof Error ? error.message : String(error)}`, + ); + return baseMessage; + } +} diff --git a/apps/api/src/handlers/ado/__tests__/handleWorkItemComment.test.ts b/apps/api/src/handlers/ado/__tests__/handleWorkItemComment.test.ts index cee725513..7c8f41562 100644 --- a/apps/api/src/handlers/ado/__tests__/handleWorkItemComment.test.ts +++ b/apps/api/src/handlers/ado/__tests__/handleWorkItemComment.test.ts @@ -55,7 +55,7 @@ vi.mock('../../utils', () => ({ })); vi.mock('../../source-control-account-linking', () => ({ - buildSourceControlAccountLinkRequiredMessage: () => + buildSourceControlAccountLinkRequiredMessage: async () => 'link your Azure DevOps account', })); diff --git a/apps/api/src/handlers/ado/handleComment.ts b/apps/api/src/handlers/ado/handleComment.ts index d3377177b..27092a287 100644 --- a/apps/api/src/handlers/ado/handleComment.ts +++ b/apps/api/src/handlers/ado/handleComment.ts @@ -313,7 +313,7 @@ export async function handleAdoComment( const body = targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('ado') + ? await buildSourceControlAccountLinkRequiredMessage('ado') : buildReviewerGateMissComment(); await postMentionResponseComment({ diff --git a/apps/api/src/handlers/ado/handleWorkItemComment.ts b/apps/api/src/handlers/ado/handleWorkItemComment.ts index 4107007e4..61905e11e 100644 --- a/apps/api/src/handlers/ado/handleWorkItemComment.ts +++ b/apps/api/src/handlers/ado/handleWorkItemComment.ts @@ -454,7 +454,7 @@ export async function handleAdoWorkItemComment( await postWorkItemMentionResponseComment({ project: projectName, workItemId, - body: buildSourceControlAccountLinkRequiredMessage('ado'), + body: await buildSourceControlAccountLinkRequiredMessage('ado'), }); return { status: 'ok', message: 'account_link_required' }; diff --git a/apps/api/src/handlers/bitbucket/__tests__/handleComment.test.ts b/apps/api/src/handlers/bitbucket/__tests__/handleComment.test.ts index f42f951ef..1928e7c24 100644 --- a/apps/api/src/handlers/bitbucket/__tests__/handleComment.test.ts +++ b/apps/api/src/handlers/bitbucket/__tests__/handleComment.test.ts @@ -39,6 +39,7 @@ vi.mock('@roomote/db/server', async (importOriginal) => { return { ...actual, + getDeploymentAccountLinkHelpText: vi.fn().mockResolvedValue(null), findActiveGitHubPrReviewTask: mockFindActiveGitHubPrReviewTask, findReusableGitHubPrFollowUpOwner: mockFindReusableGitHubPrFollowUpOwner, }; diff --git a/apps/api/src/handlers/bitbucket/handleComment.ts b/apps/api/src/handlers/bitbucket/handleComment.ts index e024ab176..0cfe55b1f 100644 --- a/apps/api/src/handlers/bitbucket/handleComment.ts +++ b/apps/api/src/handlers/bitbucket/handleComment.ts @@ -261,7 +261,7 @@ export async function handleBitbucketComment( await postMentionResponseComment({ ...mentionResponseTarget, body: requiresAccountLink - ? buildSourceControlAccountLinkRequiredMessage('bitbucket') + ? await buildSourceControlAccountLinkRequiredMessage('bitbucket') : requiresEnvironment ? buildSourceControlEnvironmentRequiredMessage('bitbucket') : buildReviewerGateMissComment(), 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__/account-link.test.ts b/apps/api/src/handlers/discord/__tests__/account-link.test.ts index 687e0086b..68cfcb600 100644 --- a/apps/api/src/handlers/discord/__tests__/account-link.test.ts +++ b/apps/api/src/handlers/discord/__tests__/account-link.test.ts @@ -3,10 +3,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const envMock = vi.hoisted(() => ({ R_APP_URL: 'https://app.example.com', })); +const appendHelpMock = vi.hoisted(() => + vi.fn(async (message: string) => message), +); vi.mock('@roomote/env', () => ({ Env: envMock, })); +vi.mock('../../account-link-help.js', () => ({ + appendAccountLinkHelpText: appendHelpMock, +})); import { buildDiscordAccountLinkFallbackInstruction, @@ -16,21 +22,37 @@ import { afterEach(() => { envMock.R_APP_URL = 'https://app.example.com'; + appendHelpMock.mockImplementation(async (message: string) => message); }); describe('Discord account-link settings copy', () => { - it('links Settings → Personal → Linked Accounts to personal settings', () => { + it('links Settings → Personal → Linked Accounts to personal settings', async () => { expect(buildDiscordAccountLinkFallbackInstruction()).toBe( 'Generate a code under [Settings → Personal → Linked Accounts](https://app.example.com/settings/personal), then DM me with `/link code:`.', ); - expect(buildDiscordLinkRequiredMessage()).toBe( + await expect(buildDiscordLinkRequiredMessage()).resolves.toBe( 'Link your Discord account to Roomote before starting tasks. Generate a code under [Settings → Personal → Linked Accounts](https://app.example.com/settings/personal), then DM me with `/link code:`.', ); - expect(buildDiscordChannelAutoStartLinkMessage('ops')).toContain( + await expect( + buildDiscordChannelAutoStartLinkMessage('ops'), + ).resolves.toContain( '[Settings → Personal → Linked Accounts](https://app.example.com/settings/personal)', ); }); + it('appends deployment help to full link prompts', async () => { + appendHelpMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); + + await expect(buildDiscordLinkRequiredMessage()).resolves.toMatch( + /Ask an admin for an invite\.$/, + ); + await expect( + buildDiscordChannelAutoStartLinkMessage('ops'), + ).resolves.toMatch(/Ask an admin for an invite\.$/); + }); + it('falls back to bold path copy when R_APP_URL is not a valid base URL', () => { envMock.R_APP_URL = 'not-a-url'; diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index eeda20be2..ab1b342b7 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -53,6 +53,12 @@ const mocks = vi.hoisted(() => ({ fetchThreadHistory: vi.fn(), shouldRouteUnmentioned: vi.fn(), enqueueGatewayEvent: vi.fn(), + callViaEmojiConfig: vi.fn(), + appendAccountLinkHelpText: vi.fn(async (message: string) => message), +})); + +vi.mock('../../account-link-help.js', () => ({ + appendAccountLinkHelpText: mocks.appendAccountLinkHelpText, })); vi.mock('@roomote/redis', async (importOriginal) => { @@ -140,6 +146,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, })); @@ -220,6 +230,9 @@ async function postIngressEvent(body: unknown, secret = 'gateway-secret') { describe('Discord Gateway event handler', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.appendAccountLinkHelpText.mockImplementation( + async (message: string) => message, + ); process.env.R_DISCORD_GATEWAY_SECRET = 'gateway-secret'; mocks.claimEvent.mockResolvedValue({ status: 'claimed', @@ -298,6 +311,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 +371,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'); @@ -1204,6 +1271,9 @@ describe('Discord Gateway event handler', () => { }); it('sends the link DM even when the dedupe check is unavailable', async () => { + mocks.appendAccountLinkHelpText.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); mocks.findMappedUserId.mockResolvedValue(null); // Redis down: the mention flow fails open so the user is not left silent. mocks.redisSet.mockRejectedValue(new Error('redis unavailable')); @@ -1227,6 +1297,11 @@ describe('Discord Gateway event handler', () => { expect(response.status).toBe(200); expect(mocks.createDirectMessage).toHaveBeenCalledWith('discord-user-1'); + expect(mocks.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Ask an admin for an invite.'), + }), + ); expect(mocks.reply).toHaveBeenCalledWith( expect.objectContaining({ text: 'I sent you a DM to link your Discord account.', @@ -1235,6 +1310,9 @@ describe('Discord Gateway event handler', () => { }); it('falls back to public link instructions when the account-link DM is blocked', async () => { + mocks.appendAccountLinkHelpText.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); mocks.findMappedUserId.mockResolvedValue(null); mocks.createDirectMessage.mockRejectedValue( new DiscordApiError({ @@ -1279,6 +1357,9 @@ describe('Discord Gateway event handler', () => { expect(mocks.reply.mock.calls[0]?.[0]?.text).toMatch( /\[Settings → Personal → Linked Accounts\]\([^)]+\/settings\/personal\)/, ); + expect(mocks.reply.mock.calls[0]?.[0]?.text).toContain( + 'Ask an admin for an invite.', + ); expect(mocks.startNewTask).not.toHaveBeenCalled(); }); @@ -1976,6 +2057,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/account-link.ts b/apps/api/src/handlers/discord/account-link.ts index e0c0dd584..6849fb043 100644 --- a/apps/api/src/handlers/discord/account-link.ts +++ b/apps/api/src/handlers/discord/account-link.ts @@ -8,6 +8,7 @@ import { Env } from '@roomote/env'; import { getRedis } from '@roomote/redis'; import { apiLogger } from '../../logging.js'; +import { appendAccountLinkHelpText } from '../account-link-help.js'; import { replyToDiscordEvent } from './replies.js'; import type { DiscordChannelContext } from './task-launch.js'; @@ -34,17 +35,21 @@ export function buildDiscordAccountLinkFallbackInstruction(): string { return `Generate a code under ${formatDiscordLinkedAccountsPath()}, then ${DISCORD_LINK_CODE_INSTRUCTION}.`; } -export function buildDiscordLinkRequiredMessage(): string { - return `Link your Discord account to Roomote before starting tasks. ${buildDiscordAccountLinkFallbackInstruction()}`; +export async function buildDiscordLinkRequiredMessage(): Promise { + return appendAccountLinkHelpText( + `Link your Discord account to Roomote before starting tasks. ${buildDiscordAccountLinkFallbackInstruction()}`, + ); } -export function buildDiscordChannelAutoStartLinkMessage( +export async function buildDiscordChannelAutoStartLinkMessage( channelName: string, -): string { - return [ - `Roomote watches **#${channelName}** and starts a task for each new message, but your Discord account is not linked to a Roomote account yet, so your message did not start one.`, - `Generate a code under ${formatDiscordLinkedAccountsPath()} in Roomote, then reply here with \`/link code:\`.`, - ].join('\n\n'); +): Promise { + return appendAccountLinkHelpText( + [ + `Roomote watches **#${channelName}** and starts a task for each new message, but your Discord account is not linked to a Roomote account yet, so your message did not start one.`, + `Generate a code under ${formatDiscordLinkedAccountsPath()} in Roomote, then reply here with \`/link code:\`.`, + ].join('\n\n'), + ); } // One link DM per user per day across every entry path (mentions, slash @@ -243,7 +248,7 @@ export async function promptDiscordAccountLink(input: { applicationId: input.applicationId, channel: input.channel, ...(input.interaction ? { interaction: input.interaction } : {}), - text: buildDiscordLinkRequiredMessage(), + text: await buildDiscordLinkRequiredMessage(), ...(input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } : {}), @@ -296,7 +301,7 @@ export async function promptDiscordAccountLink(input: { ); await input.provider.postMessage({ channelId: dmChannel.id, - text: buildDiscordLinkRequiredMessage(), + text: await buildDiscordLinkRequiredMessage(), }); dmPromptSent = true; if (slot === 'claimed') { @@ -332,7 +337,9 @@ export async function promptDiscordAccountLink(input: { text: buildAccountLinkThreadReplyText({ dmPromptSent, accountLabel: DISCORD_ACCOUNT_LABEL, - fallbackInstruction: buildDiscordAccountLinkFallbackInstruction(), + fallbackInstruction: await appendAccountLinkHelpText( + buildDiscordAccountLinkFallbackInstruction(), + ), }), ...(input.replyToMessageId ? { replyToMessageId: input.replyToMessageId } diff --git a/apps/api/src/handlers/discord/channel-auto-start.ts b/apps/api/src/handlers/discord/channel-auto-start.ts index 2cff01b55..e30e8b15c 100644 --- a/apps/api/src/handlers/discord/channel-auto-start.ts +++ b/apps/api/src/handlers/discord/channel-auto-start.ts @@ -81,7 +81,7 @@ async function sendLinkNudgeBestEffort(input: { ); await input.provider.postMessage({ channelId: dmChannel.id, - text: buildDiscordChannelAutoStartLinkMessage(input.channelName), + text: await buildDiscordChannelAutoStartLinkMessage(input.channelName), }); await markAccountLinkDmSent(input.discordUserId); } catch (error) { 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/gitea/handleComment.ts b/apps/api/src/handlers/gitea/handleComment.ts index cfda8d320..fc8ce9def 100644 --- a/apps/api/src/handlers/gitea/handleComment.ts +++ b/apps/api/src/handlers/gitea/handleComment.ts @@ -369,7 +369,7 @@ async function handleGiteaIssueComment({ body: targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('gitea') + ? await buildSourceControlAccountLinkRequiredMessage('gitea') : buildIssueGateMissComment(), }); @@ -465,7 +465,7 @@ async function handleGiteaPullRequestComment({ body: targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('gitea') + ? await buildSourceControlAccountLinkRequiredMessage('gitea') : targetsResult.status === 'error' && targetsResult.message.includes('no environment mapping') ? buildSourceControlEnvironmentRequiredMessage('gitea') diff --git a/apps/api/src/handlers/github/handleGitHubIssueComment.ts b/apps/api/src/handlers/github/handleGitHubIssueComment.ts index 4a526ebd9..abed85400 100644 --- a/apps/api/src/handlers/github/handleGitHubIssueComment.ts +++ b/apps/api/src/handlers/github/handleGitHubIssueComment.ts @@ -180,7 +180,7 @@ export async function handleGitHubIssueComment( ...replyTarget, body: commenterGate.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('github') + ? await buildSourceControlAccountLinkRequiredMessage('github') : buildGateMissComment(), }); @@ -198,7 +198,7 @@ export async function handleGitHubIssueComment( if (!target?.properties.userId) { await postIssueComment({ ...replyTarget, - body: buildSourceControlAccountLinkRequiredMessage('github'), + body: await buildSourceControlAccountLinkRequiredMessage('github'), }); return { status: 'ok', message: 'account_link_required' }; diff --git a/apps/api/src/handlers/github/handlePrComment.ts b/apps/api/src/handlers/github/handlePrComment.ts index 91e2f201a..18071def7 100644 --- a/apps/api/src/handlers/github/handlePrComment.ts +++ b/apps/api/src/handlers/github/handlePrComment.ts @@ -1195,7 +1195,7 @@ export async function handlePrComment( target: mentionResponseTarget, body: reviewerGate.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('github') + ? await buildSourceControlAccountLinkRequiredMessage('github') : buildReviewerGateMissComment(), }); diff --git a/apps/api/src/handlers/gitlab/handleNote.ts b/apps/api/src/handlers/gitlab/handleNote.ts index fdb737e11..55deb8075 100644 --- a/apps/api/src/handlers/gitlab/handleNote.ts +++ b/apps/api/src/handlers/gitlab/handleNote.ts @@ -293,7 +293,7 @@ async function handleGitLabIssueNote({ body: targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('gitlab') + ? await buildSourceControlAccountLinkRequiredMessage('gitlab') : buildIssueGateMissNote(), }); @@ -383,7 +383,7 @@ async function handleGitLabMergeRequestNote({ body: targetsResult.status === 'error' && targetsResult.code === 'account_link_required' - ? buildSourceControlAccountLinkRequiredMessage('gitlab') + ? await buildSourceControlAccountLinkRequiredMessage('gitlab') : buildReviewerGateMissNote(), }); diff --git a/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts b/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts index 9c04b42fe..019162be1 100644 --- a/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts @@ -65,6 +65,15 @@ function createInitializeRequest(id: number) { }; } +function createToolsListRequest(id: number) { + return { + jsonrpc: '2.0', + id, + method: 'tools/list', + params: {}, + }; +} + function createApp( integrationId: string, authContext: Variables['authContext'], @@ -188,4 +197,70 @@ describe('createIntegrationMcpProxy acting-user scoping', () => { expect(response.status).toBe(200); expect(mockFindConnection).toHaveBeenCalledTimes(1); }); + + it('strips Resend tool schema patterns for Azure-compatible tool calls', async () => { + mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: null }); + mockFindConnection.mockResolvedValue({ id: 'conn-1', userId: null }); + + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + tools: [ + { + name: 'create-contact', + inputSchema: { + type: 'object', + properties: { + email: { + type: 'string', + description: 'Contact email address', + pattern: '^(?!\\.)lookaround-pattern$', + }, + nested: { + type: 'array', + items: { type: 'string', pattern: '^nested$' }, + }, + }, + }, + }, + ], + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await postMcp( + createApp('resend', createRunToken()), + createToolsListRequest(1), + ); + const body = (await response.json()) as { + result: { + tools: Array<{ + inputSchema: { + properties: { + email: Record; + nested: { items: Record }; + }; + }; + }>; + }; + }; + + expect(response.status).toBe(200); + expect(body.result.tools[0]?.inputSchema.properties.email).toEqual({ + type: 'string', + description: 'Contact email address', + }); + expect(body.result.tools[0]?.inputSchema.properties.nested.items).toEqual({ + type: 'string', + }); + }); }); diff --git a/apps/api/src/handlers/mcp/integration-mcp.ts b/apps/api/src/handlers/mcp/integration-mcp.ts index 7e29a2b69..5b1148cff 100644 --- a/apps/api/src/handlers/mcp/integration-mcp.ts +++ b/apps/api/src/handlers/mcp/integration-mcp.ts @@ -98,6 +98,9 @@ export function createIntegrationMcpProxy( upstream: upstreamUrl, allowAuthTokens: options?.allowAuthTokens, allowedToolNames: options?.allowedToolNames, + // Resend's z.email() tool schemas include regex lookarounds that Azure + // OpenAI rejects. The upstream Resend server still validates tool calls. + stripToolSchemaPatterns: integration.id === 'resend', // Integration OAuth MCPs resolve acting-user credentials directly. validateTaskRunToken: async () => null, resolveCredentials: async (auth) => { diff --git a/apps/api/src/handlers/mcp/proxy-utils.ts b/apps/api/src/handlers/mcp/proxy-utils.ts index 96c4f790b..2becc053c 100644 --- a/apps/api/src/handlers/mcp/proxy-utils.ts +++ b/apps/api/src/handlers/mcp/proxy-utils.ts @@ -307,6 +307,7 @@ interface McpProxyConfig { allowAuthTokens?: boolean; validateTaskRunToken?: (auth: RunTokenContext) => Promise; allowedToolNames?: readonly string[]; + stripToolSchemaPatterns?: boolean; timeoutMs?: number; } @@ -359,12 +360,37 @@ function isExpectedProxyDisconnect(error: unknown): error is DOMException { ); } +/** + * Resend's MCP uses Zod's email schema, which serializes to a JSON Schema + * `pattern` containing regex lookarounds. Azure OpenAI rejects `pattern` in + * tool schemas, while Resend still validates the actual tool call upstream. + * Strip only the model-facing keyword at this proxy boundary. + */ +function stripToolSchemaPatterns(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stripToolSchemaPatterns); + } + + if (!value || typeof value !== 'object') { + return value; + } + + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => key !== 'pattern') + .map(([key, nestedValue]) => [key, stripToolSchemaPatterns(nestedValue)]), + ); +} + function filterToolsListPayload( payload: unknown, toolPolicy: { allowedToolNames?: readonly string[]; disabledToolNames?: readonly string[] | null; }, + options?: { + stripToolSchemaPatterns?: boolean; + }, ): unknown { if (!payload || typeof payload !== 'object') { return payload; @@ -393,11 +419,15 @@ function filterToolsListPayload( ), ); + const filteredTools = filterMcpToolDefinitions(namedTools, toolPolicy); + return { ...payload, result: { ...result, - tools: filterMcpToolDefinitions(namedTools, toolPolicy), + tools: options?.stripToolSchemaPatterns + ? stripToolSchemaPatterns(filteredTools) + : filteredTools, }, }; } @@ -411,6 +441,7 @@ export function createMcpProxy(config: McpProxyConfig) { allowAuthTokens = false, validateTaskRunToken = verifyTaskRunTokenTargetExists, allowedToolNames, + stripToolSchemaPatterns: shouldStripToolSchemaPatterns = false, } = config; const app = new Hono<{ Variables: Variables }>(); @@ -652,7 +683,7 @@ export function createMcpProxy(config: McpProxyConfig) { } if ( - hasToolRestrictions && + (hasToolRestrictions || shouldStripToolSchemaPatterns) && method === 'POST' && getJsonRpcMethod(parsedBody) === 'tools/list' && upstreamResponse.ok @@ -665,10 +696,16 @@ export function createMcpProxy(config: McpProxyConfig) { if (!payload) { throw new Error('Unable to parse upstream tools/list payload'); } - const filteredPayload = filterToolsListPayload(payload, { - allowedToolNames: effectiveAllowedToolNames, - disabledToolNames: credentials.disabledToolNames, - }); + const filteredPayload = filterToolsListPayload( + payload, + { + allowedToolNames: effectiveAllowedToolNames, + disabledToolNames: credentials.disabledToolNames, + }, + { + stripToolSchemaPatterns: shouldStripToolSchemaPatterns, + }, + ); const headers = buildProxyResponseHeaders(upstreamResponse.headers); headers.set('content-type', 'application/json'); 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/source-control-account-linking.test.ts b/apps/api/src/handlers/source-control-account-linking.test.ts new file mode 100644 index 000000000..d90e79d02 --- /dev/null +++ b/apps/api/src/handlers/source-control-account-linking.test.ts @@ -0,0 +1,32 @@ +const { appendHelpMock, envMock } = vi.hoisted(() => ({ + appendHelpMock: vi.fn(async (message: string) => `${message} Custom help.`), + envMock: { R_APP_URL: 'https://app.example.com' }, +})); + +vi.mock('@roomote/env', () => ({ Env: envMock })); +vi.mock('./account-link-help.js', () => ({ + appendAccountLinkHelpText: appendHelpMock, +})); + +import { buildSourceControlAccountLinkRequiredMessage } from './source-control-account-linking'; + +describe('buildSourceControlAccountLinkRequiredMessage', () => { + it('keeps provider copy and appends deployment help', async () => { + await expect( + buildSourceControlAccountLinkRequiredMessage('github'), + ).resolves.toContain( + '[Settings -> Linked Accounts](https://app.example.com/settings?service=github)', + ); + await expect( + buildSourceControlAccountLinkRequiredMessage('github'), + ).resolves.toMatch(/mention me again\. Custom help\.$/); + }); + + it('keeps provider setup guidance before deployment help', async () => { + await expect( + buildSourceControlAccountLinkRequiredMessage('gitlab'), + ).resolves.toMatch( + /add the GitLab OAuth client credentials.*first\. Custom help\.$/, + ); + }); +}); diff --git a/apps/api/src/handlers/source-control-account-linking.ts b/apps/api/src/handlers/source-control-account-linking.ts index 77647dbd7..a5d622cee 100644 --- a/apps/api/src/handlers/source-control-account-linking.ts +++ b/apps/api/src/handlers/source-control-account-linking.ts @@ -1,6 +1,8 @@ import { Env } from '@roomote/env'; import { PRODUCT_NAME } from '@roomote/types'; +import { appendAccountLinkHelpText } from './account-link-help.js'; + type SourceControlCommentProvider = | 'github' | 'gitlab' @@ -78,9 +80,9 @@ export function buildSourceControlEnvironmentRequiredMessage( return `I saw the mention, but no Roomote environment is mapped to this ${copy.accountLabel} repository. Set up an environment and map this repository from ${settingsText}, then mention me again.`; } -export function buildSourceControlAccountLinkRequiredMessage( +export async function buildSourceControlAccountLinkRequiredMessage( provider: SourceControlCommentProvider, -): string { +): Promise { const copy = sourceControlCommentProviderCopy[provider]; const settingsUrl = getLinkedAccountsSettingsUrl(provider); @@ -95,8 +97,12 @@ export function buildSourceControlAccountLinkRequiredMessage( provider === 'bitbucket' || provider === 'ado' ) { - return `I saw the mention, but I need your ${copy.accountLabel} account linked to ${PRODUCT_NAME} before ${copy.commentSurface} can start work here. ${linkInstruction} If ${copy.accountLabel} is missing from Linked Accounts, ask an admin to add the ${copy.accountLabel} OAuth client credentials in Settings -> Environments -> Source Control first.`; + return appendAccountLinkHelpText( + `I saw the mention, but I need your ${copy.accountLabel} account linked to ${PRODUCT_NAME} before ${copy.commentSurface} can start work here. ${linkInstruction} If ${copy.accountLabel} is missing from Linked Accounts, ask an admin to add the ${copy.accountLabel} OAuth client credentials in Settings -> Environments -> Source Control first.`, + ); } - return `I saw the mention, but I need your ${copy.accountLabel} account linked to ${PRODUCT_NAME} before ${copy.commentSurface} can start work here. ${linkInstruction}`; + return appendAccountLinkHelpText( + `I saw the mention, but I need your ${copy.accountLabel} account linked to ${PRODUCT_NAME} before ${copy.commentSurface} can start work here. ${linkInstruction}`, + ); } 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/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index 5aae6c763..5c33fb1fa 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -39,6 +39,7 @@ const { updateReturningMock, usersFindFirstMock, telegramMappingsFindFirstMock, + appendAccountLinkHelpTextMock, } = vi.hoisted(() => ({ addReactionMock: vi.fn(), answerCallbackQueryMock: vi.fn(), @@ -82,12 +83,17 @@ const { updateReturningMock: vi.fn(), usersFindFirstMock: vi.fn(), telegramMappingsFindFirstMock: vi.fn(), + appendAccountLinkHelpTextMock: vi.fn(async (message: string) => message), })); vi.mock('@roomote/env', () => ({ Env: envMock, })); +vi.mock('../../account-link-help.js', () => ({ + appendAccountLinkHelpText: appendAccountLinkHelpTextMock, +})); + vi.mock('@roomote/redis', () => ({ getRedis: vi.fn(() => ({ set: redisSetMock, @@ -359,6 +365,9 @@ function mockTelegramLinkedSender(userId = 'launch-owner-1') { describe('Telegram webhook handler', () => { beforeEach(() => { vi.clearAllMocks(); + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => message, + ); // Some tests queue one-shot lookup results on these mocks. Reset them so // later tests do not inherit stale values when the whole suite runs. @@ -484,6 +493,9 @@ describe('Telegram webhook handler', () => { }); it('nudges an unlinked sender to link and drops the message', async () => { + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); const response = await postTelegramUpdate(createTelegramUpdate()); await expect(response.json()).resolves.toEqual({ @@ -509,9 +521,15 @@ describe('Telegram webhook handler', () => { textFormat: 'markdown', }), ); + expect(postMessageMock.mock.calls[0]?.[0].text).toContain( + 'Ask an admin for an invite.', + ); }); it('nudges an unlinked group sender who addressed the bot with a deep-link button', async () => { + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); const response = await postTelegramUpdate( createTelegramUpdate({ message: { @@ -521,6 +539,9 @@ describe('Telegram webhook handler', () => { }, }), ); + expect(postMessageMock.mock.calls[0]?.[0].text).toContain( + 'Ask an admin for an invite.', + ); await expect(response.json()).resolves.toEqual({ ok: true, @@ -636,6 +657,9 @@ describe('Telegram webhook handler', () => { }); it('replies with linking instructions to the /start link deep link', async () => { + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); const response = await postTelegramUpdate( createTelegramUpdate({ message: { @@ -644,6 +668,9 @@ describe('Telegram webhook handler', () => { }, }), ); + expect(postMessageMock.mock.calls[0]?.[0].text).toContain( + 'Ask an admin for an invite.', + ); await expect(response.json()).resolves.toEqual({ ok: true, @@ -1386,6 +1413,9 @@ describe('Telegram webhook handler', () => { }); it('welcomes bare /start commands from an unlinked sender', async () => { + appendAccountLinkHelpTextMock.mockImplementation( + async (message: string) => `${message} Ask an admin for an invite.`, + ); const response = await postTelegramUpdate( createTelegramUpdate({ message: { @@ -1414,6 +1444,9 @@ describe('Telegram webhook handler', () => { textFormat: 'markdown', }), ); + expect(postMessageMock.mock.calls[0]?.[0].text).toContain( + 'Ask an admin for an invite.', + ); }); it('nudges unlinked senders to link their account in the /start welcome', async () => { diff --git a/apps/api/src/handlers/telegram/index.ts b/apps/api/src/handlers/telegram/index.ts index 7ca9813a0..0c5d6d668 100644 --- a/apps/api/src/handlers/telegram/index.ts +++ b/apps/api/src/handlers/telegram/index.ts @@ -81,6 +81,7 @@ import { rememberTelegramImplicitTopic, verifyTelegramWebhookSecret, } from './webhook-gate.js'; +import { appendAccountLinkHelpText } from '../account-link-help.js'; // Deep-link payload used by the group "link account" button: tapping // https://t.me/?start=link opens the bot's DM with "/start link". @@ -248,7 +249,9 @@ telegram.post('/', async (c) => { chatId: String(message.chat.id), text: senderUserId ? '✅ This Telegram account is already linked to your Roomote account — head back to the group and send your request again.' - : 'Let’s link your Telegram account: generate a code under *Settings → Personal → Linked Accounts* in Roomote, then send it here.', + : await appendAccountLinkHelpText( + 'Let’s link your Telegram account: generate a code under *Settings → Personal → Linked Accounts* in Roomote, then send it here.', + ), textFormat: 'markdown', }); @@ -270,7 +273,7 @@ telegram.post('/', async (c) => { chatId: String(message.chat.id), text: senderUserId ? TELEGRAM_WELCOME_MESSAGE - : `${TELEGRAM_WELCOME_MESSAGE}\n\n${TELEGRAM_WELCOME_LINK_NUDGE}`, + : `${TELEGRAM_WELCOME_MESSAGE}\n\n${await appendAccountLinkHelpText(TELEGRAM_WELCOME_LINK_NUDGE)}`, textFormat: 'markdown', }); @@ -287,7 +290,7 @@ telegram.post('/', async (c) => { if (isTelegramPrivateChat(message)) { await postTelegramMessageBestEffort({ chatId: String(message.chat.id), - text: TELEGRAM_LINK_REQUIRED_MESSAGE, + text: await appendAccountLinkHelpText(TELEGRAM_LINK_REQUIRED_MESSAGE), textFormat: 'markdown', }); } else { @@ -314,7 +317,10 @@ telegram.post('/', async (c) => { replyToMessageId: nudgeMetadata.communicationMessageId, ...(botUsername ? { - text: 'Link your Telegram account to Roomote first, then send your request again.', + text: await appendAccountLinkHelpText( + 'Link your Telegram account to Roomote first, then send your request again.', + ), + textFormat: 'markdown' as const, buttons: [ [ { @@ -325,7 +331,9 @@ telegram.post('/', async (c) => { ], } : { - text: TELEGRAM_LINK_REQUIRED_MESSAGE, + text: await appendAccountLinkHelpText( + TELEGRAM_LINK_REQUIRED_MESSAGE, + ), textFormat: 'markdown' as const, }), }); 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/AGENTS.md b/apps/docs/AGENTS.md index 5a9f3841d..dacfd36c2 100644 --- a/apps/docs/AGENTS.md +++ b/apps/docs/AGENTS.md @@ -80,10 +80,23 @@ Provider ids and documentation slugs can differ (for example, `google` maps to `google-gemini`), so make that mapping explicit and cover it with the setup docs tests. +### Cookbook recipes + +Add each Cookbook recipe as a standalone `cookbook/.mdx` file using +`cookbook/template.mdx` as the structural guide. After adding or changing a +recipe, run `pnpm --filter @roomote/docs generate-cookbook-index` so the table in +`cookbook/index.mdx` stays alphabetized by frontmatter title. Do not edit the +generated table between its `cookbook-recipes` markers by hand. + +Never add individual Cookbook recipe pages to `docs.json`. Only +`cookbook/index` and `cookbook/template` belong in the Cookbook sidebar group; +readers discover recipes through the generated index. + ## Working notes - `docs.json` is the navigation and branding source of truth. When you add, - rename, or remove a page, update its `navigation` entry in the same change. + rename, or remove a page, update its `navigation` entry in the same change, + except for individual Cookbook recipes as described above. - Pages are MDX files referenced by file name (without extension). - Internal links use root-relative paths (`/environments`, not `/docs/...`). - Brand assets (`roomote.css`, `logo/`, `favicon.svg`, `fonts/`) live in this diff --git a/apps/docs/anonymous-analytics.mdx b/apps/docs/anonymous-analytics.mdx index b2053d7c2..e1bfbf7d7 100644 --- a/apps/docs/anonymous-analytics.mdx +++ b/apps/docs/anonymous-analytics.mdx @@ -15,8 +15,13 @@ When anonymous telemetry is enabled, your deployment sends: - **Usage events** — page views (as route patterns like `/task/[taskId]`, never actual URLs or IDs) and product events such as tasks being created - or settling as completed, failed, or canceled, with non-identifying facts - like the harness, model, source surface, and sandbox provider used. + or settling as completed, failed, or canceled, plus setup progress such as + reaching authenticated setup and configuring or connecting communications, + source-control, inference, and sandbox providers. Setup events include only + the provider type and, where Roomote can determine it from deployment state, + whether it was configured before the wizard. Other events include + non-identifying facts like the harness, model, source surface, and sandbox + provider used. - **A daily instance report** — aggregate deployment metadata and usage: setup timestamps; counts of users, environments, and connected repositories; task, model, token, and cost totals for the past day; pull-request statistics diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 1dbb83c80..36aa8665b 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -112,6 +112,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/docs/cookbook/ease-your-team-into-cloud-agents.mdx b/apps/docs/cookbook/ease-your-team-into-cloud-agents.mdx new file mode 100644 index 000000000..49ca549ba --- /dev/null +++ b/apps/docs/cookbook/ease-your-team-into-cloud-agents.mdx @@ -0,0 +1,97 @@ +--- +title: Ease your team into cloud agents +description: Build trust in Roomote through small, visible, low-risk team habits. +contributor: Bruno Bergher +contributor_url: https://github.com/brunobergher +contributor_company: Roomote +contributor_company_url: https://roomote.dev +--- + +## Overview + +Cloud agents can feel uncomfortable at first, especially when using one means +asking a question in public or letting it comment on a teammate's work. Do not +start by automating everything. Give the team a few low-risk ways to watch +Roomote work, understand what it can do, and see leaders review its output. + +The goal is familiarity, not maximum usage. Begin with questions and code +reviews, keep the work visible, and add more autonomy only after the team trusts +the results. + +- **Trigger**: Channel activity and pull request updates +- **Setup time**: About 20 minutes +- **Requires**: Admin access, Slack, a source-control connection, a healthy environment +- **Serves**: Engineers, Leads +- **Cooked By**: [Bruno Bergher](https://github.com/brunobergher) from [Roomote](https://roomote.dev) + +## Ingredients + +- A healthy [environment](/environments) for the team's main repositories +- Two Slack channels, such as `#factory` and `#ask-roomote`, with Roomote invited +- The **Review Code** automation +- One or two leaders willing to use Roomote where the team can watch + +## Steps + +1. Create a `#factory` channel for visible examples. Have leaders bring real, + small tasks there, include the context Roomote needs, and review the result + in the thread. Leave corrections visible too; showing how to steer an agent + is more useful than showing only perfect outcomes. +2. Create an `#ask-roomote` channel for codebase questions. Start with explicit + mentions, or configure [auto-response](/automations#channel-automations) once + the channel's purpose is clear. Encourage questions such as where a behavior + lives, how a flow works, or which tests cover a change. +3. Have a leader ask Roomote to add an unmistakable custom emoji reaction to a + specific Slack message. This small, visible action shows that Roomote can help + with more than code without asking the team to configure another automation. +4. Enable **Review Code**, then turn on **Review PRs not created by Roomote** so + it includes pull requests opened by engineers. Keep automatic draft reviews + off at first. Roomote adds a second opinion without taking control of the pull + request, which lets engineers compare its comments with their own review and + build trust gradually. +5. Keep the first tasks read-only. Ask for explanations, investigation, and + review before asking Roomote to change code. When the team is comfortable, + let a leader demonstrate one small implementation and walk through the diff, + checks, and pull request. +6. Close the loop in public. When Roomote helps, say what was useful. When it + misses, reply with the missing context and show the corrected result. This + teaches the team that agent output is reviewable work, not a verdict. +7. Offer direct messages as a rehearsal space for anyone who is not ready to + ask publicly. Invite people to share useful answers back in `#ask-roomote` so + the team's shared confidence still grows. + +## Starter prompts + +Use questions that are easy for an engineer to verify: + +```text +Where is authentication handled in this codebase? Link the important files and +explain the request flow. Do not change code. +``` + +```text +Which tests cover this pull request's behavior, and what important case might +still be missing? Do not change code. +``` + +```text +Explain why this service exists and which other parts of the system call it. +Keep the answer short and link to the relevant code. +``` + +```text +Add the :eyes: reaction to the message linked below. Do not post a reply. +``` + +## Variations + +- Start `#ask-roomote` with office hours led by one champion, then leave it open all week once the questions become routine. +- Ask leaders to post one useful Roomote thread in an existing engineering channel each week instead of creating `#factory` permanently. +- Begin **Review Code** with a small set of repositories before enabling it across the organization. + +Avoid adoption targets such as a required number of agent tasks per engineer. +Watch for better signals: more people asking verifiable questions, teammates +replying naturally in Roomote threads, and engineers acting on review comments +they checked themselves. + +**Pairs well with:** [scheduled housekeeping](/cookbook/scheduled-housekeeping) diff --git a/apps/docs/cookbook/index.mdx b/apps/docs/cookbook/index.mdx index d2172a276..5d68fcc28 100644 --- a/apps/docs/cookbook/index.mdx +++ b/apps/docs/cookbook/index.mdx @@ -13,6 +13,7 @@ and the quality of your output. | Recipe | Use to | | --- | --- | | [Draft product updates](/cookbook/product-updates-newsletter) | Turn recent product work into a customer-ready draft | +| [Ease your team into cloud agents](/cookbook/ease-your-team-into-cloud-agents) | Build trust in Roomote through small, visible, low-risk team habits. | | [Evaluate outage impact](/cookbook/vendor-outage-triage) | Filter vendor status noise by comparing each incident with your real code, regions, and feature usage. | | [Fix CI failures](/cookbook/ci-failure-auto-fix) | Keep the build green by having Roomote verify and fix CI breakages automatically. | | [Schedule maintenance](/cookbook/scheduled-housekeeping) | Turn flaky-test scans, feature-flag audits, and dependency reviews into recurring Roomote work. | diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 71155bbe9..f53fc9698 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -145,6 +145,7 @@ "integrations/posthog", "integrations/pylon", "integrations/railway", + "integrations/resend", "integrations/sentry", "integrations/snowflake", "integrations/supabase", diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index 24fd1d9c9..6419ea307 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -63,6 +63,7 @@ from [Personal Settings](/personal-settings). | | Product analytics, experiments, and error context | Admin connection once | | | Customer issue and account context | Admin connection once | | | Project and service context from Railway | Admin connection once | +| | Email delivery and infrastructure management | Admin connection once | | | Error and performance investigation | Admin connection once | | | Data warehouse exploration | Admin connection once | | | Read-only database access in Supabase | Enable first, then teammates link accounts | diff --git a/apps/docs/integrations/resend.mdx b/apps/docs/integrations/resend.mdx new file mode 100644 index 000000000..16d617dfb --- /dev/null +++ b/apps/docs/integrations/resend.mdx @@ -0,0 +1,45 @@ +--- +title: Resend +description: Inspect and manage shared email infrastructure from Roomote tasks. +icon: 'https://api.iconify.design/simple-icons:resend.svg?color=currentColor' +--- + +Connect Resend when tasks need email delivery status, received messages, +domains, contacts, templates, broadcasts, or other email infrastructure. + +## How setup works + +An admin connects Resend once from **Settings > Integrations** using OAuth. The +connection is shared across the deployment and requests Resend's +`full_access` scope so Roomote can use inspection and management tools. + +## Safer defaults + +Roomote initially disables tools that send or reschedule email, create or +remove API credentials, mutate domains or webhooks, mutate or trigger +automations, and mutate contacts. Read operations remain available, and +canceling a pending scheduled email remains enabled as a safety action. + +The disabled tools include: + +- single, batch, and broadcast sending +- rescheduling a scheduled email +- creating or removing API keys that could bypass Roomote's tool policy +- creating or updating automations, or sending events that trigger them +- updating or removing domains +- creating or updating webhooks +- creating, updating, or removing contacts +- creating, updating, or removing contact properties +- changing contact segment or topic membership +- importing contacts from CSV + +An admin can opt in to individual tools from **Settings > Integrations > +Resend > Manage tools**. Disabled tools are hidden from tasks and rejected by +the server if called directly. Admin choices persist when Resend is reconnected +or disabled and enabled again. + +## Verify the connection + +After connecting Resend, start with a read-only request such as listing recent +emails or checking domain status. Enable only the additional tools your team +expects Roomote to use. diff --git a/apps/docs/scripts/generate-cookbook-index.mjs b/apps/docs/scripts/generate-cookbook-index.mjs index dd85ff042..daf11dcd1 100644 --- a/apps/docs/scripts/generate-cookbook-index.mjs +++ b/apps/docs/scripts/generate-cookbook-index.mjs @@ -60,7 +60,9 @@ async function readRecipe(fileName) { function renderTable(recipes) { const rows = recipes - .sort((left, right) => left.title.localeCompare(right.title)) + .sort((left, right) => + left.title.localeCompare(right.title, 'en', { sensitivity: 'base' }), + ) .map((recipe) => { return `| [${escapeTableCell(recipe.title)}](${recipe.slug}) | ${escapeTableCell(recipe.description)} |`; }); diff --git a/apps/docs/users.mdx b/apps/docs/users.mdx index f8a87af5b..64d4323e9 100644 --- a/apps/docs/users.mdx +++ b/apps/docs/users.mdx @@ -112,6 +112,20 @@ it before leaving the page. You can revoke an invite before it is used. Revoking an invite does not affect people who already joined with it. +## Customize account linking help + +Admins can add deployment-specific guidance under **Settings > Users > Account +linking help**. Roomote appends this text when an unlinked user tries to start +work from a source-control comment, Discord, or Telegram. + +Use it to explain how someone can request an invite or whom to contact. Markdown +links are supported, but plain text with a full URL works across every supported +surface. Leave the field blank to use Roomote's built-in account linking message +without extra guidance. + +Slack and Microsoft Teams prompts do not use this setting because users enter +through their configured workspace or tenant rather than an invite. + ## Manage existing users The user list shows active users, their email address, join date, and current diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx b/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx index d64a29653..9bd7e6421 100644 --- a/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx @@ -8,6 +8,7 @@ let enabledMcpIds: string[] = []; let linkedMcpIds: string[] = []; let orgHasLinear = false; let userHasLinkedLinear = false; +let hasEnabledAutomations = true; const { mockPush, @@ -103,7 +104,7 @@ vi.mock('@tanstack/react-query', () => ({ }, isPending: false, } - : { data: { hasEnabledAutomations: true }, isPending: false }, + : { data: { hasEnabledAutomations }, isPending: false }, })); vi.mock('motion/react', async () => { @@ -163,10 +164,32 @@ beforeEach(() => { linkedMcpIds = []; orgHasLinear = false; userHasLinkedLinear = false; + hasEnabledAutomations = true; localStorage.clear(); vi.clearAllMocks(); }); +it('prioritizes automations and opens the automations page', () => { + hasEnabledAutomations = false; + linkableProviders = [ + { + id: 'slack', + category: 'communication', + label: 'Slack', + configured: true, + linked: false, + }, + ]; + + render(); + expect( + screen.getByText("Put your team's work on autopilot with automations"), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Go' })); + expect(mockPush).toHaveBeenCalledWith('/automations'); +}); + it('prioritizes communication accounts before source-control accounts', () => { linkableProviders = [ { diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx index 498a63cb2..e3cc93134 100644 --- a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx +++ b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx @@ -429,6 +429,22 @@ export function OnboardingCard() { }; const cards: CardConfig[] = [ + { + id: 'automations', + icon: ( + + ), + label: "Put your team's work on autopilot with automations", + buttonLabel: 'Go', + onClick: () => router.push('/automations'), + visible: + !automationsPending && + Boolean(automationOnboardingStatus) && + !automationOnboardingStatus?.hasEnabledAutomations, + }, { id: 'link-suggested-tasks', icon: , @@ -447,22 +463,6 @@ export function OnboardingCard() { ...adminIntegrationCards, ...personalMcpCards, linearPersonalCard, - { - id: 'automations', - icon: ( - - ), - label: 'Automations keep your repos moving in the background', - buttonLabel: 'Set them up', - onClick: () => router.push(SETTINGS_PATHS.automations), - visible: - !automationsPending && - Boolean(automationOnboardingStatus) && - !automationOnboardingStatus?.hasEnabledAutomations, - }, ]; const activeCard = cards.find((card) => card.visible && !dismissed[card.id]); diff --git a/apps/web/src/app/(onboarding)/setup/SetupBootstrapFlow.tsx b/apps/web/src/app/(onboarding)/setup/SetupBootstrapFlow.tsx index 464d1a279..f023ac2e5 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupBootstrapFlow.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupBootstrapFlow.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion, type Variants } from 'motion/react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { useRedirectToSignIn } from '@/hooks/useSignInRedirect'; @@ -94,6 +94,9 @@ export function SetupBootstrapFlow() { }, ), ); + const trackWelcomeSeen = useMutation( + trpc.setupBootstrap.trackWelcomeSeen.mutationOptions(), + ); const bootstrapAuthProvider = getBootstrapAuthProvider( bootstrapStatus?.authSetup, pendingSetupAuthProvider, @@ -225,11 +228,14 @@ export function SetupBootstrapFlow() { > {bootstrapStep === 'welcome' && ( + onContinue={() => { + trackWelcomeSeen.mutate( + setupToken ? { setupToken } : undefined, + ); setBootstrapStepWithTransition( getBootstrapStepAfterWelcome(bootstrapStatus.authSetup), - ) - } + ); + }} /> )} {bootstrapStep === 'email-account' && ( diff --git a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx index cb536f36c..1494b14ab 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupSignedInFlow.tsx @@ -80,6 +80,9 @@ export function SetupSignedInFlow() { useState(null); const [pendingModelProvider, setPendingModelProvider] = useState(null); + const trackWelcomeSeen = useMutation( + trpc.setupNew.trackWelcomeSeen.mutationOptions(), + ); const { data: setupStatus, isLoading: isSetupStatusLoading, @@ -291,7 +294,14 @@ export function SetupSignedInFlow() { animate="center" exit="exit" > - {step === 'welcome' && } + {step === 'welcome' && ( + { + trackWelcomeSeen.mutate(); + goToNextStep(); + }} + /> + )} {step === 'auth-provider' && ( ({ - connectSlackMutateMock: vi.fn(), - teamsStatusState: { - data: { - botConfigured: true, - botUsesTenantSpecificTokenFlow: false, - microsoftAuthConfigured: true, - webhookUrl: 'https://roomote.example.com/api/webhooks/teams', - openInTeamsUrl: - 'https://teams.microsoft.com/l/chat/0/0?users=28%3Abot-app-id' as - | string - | null, - botName: 'Roomote', - primaryConversationReady: true, - primaryConversationType: 'channel' as string | null, +const { connectSlackMutateMock, trackMilestoneMock, teamsStatusState } = + vi.hoisted(() => ({ + connectSlackMutateMock: vi.fn(), + trackMilestoneMock: vi.fn(), + teamsStatusState: { + data: { + botConfigured: true, + botUsesTenantSpecificTokenFlow: false, + microsoftAuthConfigured: true, + webhookUrl: 'https://roomote.example.com/api/webhooks/teams', + openInTeamsUrl: + 'https://teams.microsoft.com/l/chat/0/0?users=28%3Abot-app-id' as + | string + | null, + botName: 'Roomote', + primaryConversationReady: true, + primaryConversationType: 'channel' as string | null, + }, + isPending: false, + isError: false, }, - isPending: false, - isError: false, - }, + })); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ mutate: trackMilestoneMock }), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + setupNew: { + trackCommsState: { mutationOptions: () => ({}) }, + }, + }), })); vi.mock('@/hooks/slack', () => ({ diff --git a/apps/web/src/app/(onboarding)/setup/StepCommunicationConnect.tsx b/apps/web/src/app/(onboarding)/setup/StepCommunicationConnect.tsx index 8bc4f825a..34b232425 100644 --- a/apps/web/src/app/(onboarding)/setup/StepCommunicationConnect.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepCommunicationConnect.tsx @@ -1,10 +1,13 @@ 'use client'; +import { useEffect, useRef } from 'react'; +import { useMutation } from '@tanstack/react-query'; import type { SetupAuthStatus } from '@roomote/types'; import { toast } from 'sonner'; import { useConnectSlack } from '@/hooks/slack'; import { useTeamsIntegrationStatus } from '@/hooks/teams'; +import { useTRPC } from '@/trpc/client'; import { TaskStatusIndicator } from '@/components/sandbox'; import { ArrowRight, @@ -41,6 +44,7 @@ export function StepCommunicationConnect({ onBack?: () => void; returnPath?: string; }) { + const trpc = useTRPC(); const provider = getCommunicationProvider(authSetup); const connectSlack = useConnectSlack(returnPath, { onSuccess: (url) => { @@ -49,6 +53,41 @@ export function StepCommunicationConnect({ onError: () => toast.error('Failed to connect Slack. Please try again.'), }); const teamsIntegrationStatus = useTeamsIntegrationStatus(); + const configuredTrackedRef = useRef(false); + const authedTrackedRef = useRef(false); + const trackCommsState = useMutation( + trpc.setupNew.trackCommsState.mutationOptions(), + ); + const teamsStatus = teamsIntegrationStatus.data; + const teamsConfigured = + teamsStatus?.botConfigured === true && teamsStatus.microsoftAuthConfigured; + const primaryConversationReady = Boolean( + teamsStatus?.primaryConversationReady, + ); + useEffect(() => { + if ( + provider === 'microsoft' && + teamsConfigured && + !configuredTrackedRef.current + ) { + configuredTrackedRef.current = true; + trackCommsState.mutate({ + provider: 'microsoft', + }); + } + }, [provider, teamsConfigured, trackCommsState]); + useEffect(() => { + if ( + provider === 'microsoft' && + primaryConversationReady && + !authedTrackedRef.current + ) { + authedTrackedRef.current = true; + trackCommsState.mutate({ + provider: 'microsoft', + }); + } + }, [primaryConversationReady, provider, trackCommsState]); const skipLink = ( )} {hideModeSwitchMessage ? null : ( -

- Need an account? Forgot your password? -
- Ask your admin. -

+
+

+ Need an account? Forgot your password? +
+ Ask your admin. +

+ {accountLinkHelpText ? ( + + {accountLinkHelpText} + + ) : null} +
)} ); diff --git a/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.client.tsx b/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.client.tsx index 990f99d22..a6ed5bcc3 100644 --- a/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.client.tsx +++ b/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.client.tsx @@ -11,12 +11,14 @@ export function SignInPageClient({ inviteRole = null, inviteInvalid = false, seatLimitBlocked = false, + accountLinkHelpText = null, }: { enabledProviders: AuthProvider[]; canSignUp: boolean; inviteRole?: UserRole | null; inviteInvalid?: boolean; seatLimitBlocked?: boolean; + accountLinkHelpText?: string | null; }) { useSetAuthState(); @@ -25,6 +27,7 @@ export function SignInPageClient({ enabledProviders={enabledProviders} canSignUp={canSignUp} inviteRole={inviteRole} + accountLinkHelpText={accountLinkHelpText} noticeMessage={ seatLimitBlocked ? 'This deployment has reached its licensed user limit. Ask an admin to free a seat or add a license key, then sign in again.' diff --git a/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.tsx b/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.tsx index 06f390db8..27d010b0a 100644 --- a/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.tsx +++ b/apps/web/src/app/(unauthenticated)/sign-in/[[...sign-in]]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { getDeploymentAccountLinkHelpText } from '@roomote/db/server'; import { canVisitorSignUp, @@ -41,14 +42,18 @@ export default async function Page(props: { // Whether the visitor arrived with a usable invite (the /invite/ // route stores it in the invite cookie) or bootstrap rights; without one, // the form offers sign-in only and account creation stays hidden. - const canSignUp = await canVisitorSignUp(); - const invite = await getRequestInviteSummary(); - const searchParams = await props.searchParams; + const [canSignUp, invite, searchParams, authContext, accountLinkHelpText] = + await Promise.all([ + canVisitorSignUp(), + getRequestInviteSummary(), + props.searchParams, + getSignedInAuthContext(), + getDeploymentAccountLinkHelpText(), + ]); // A visitor bounced here by the seat gate still holds their Better Auth // session cookie, so re-running the auth evaluation identifies them and // lets the form explain the rejection instead of silently offering // sign-in again. - const authContext = await getSignedInAuthContext(); const seatLimitBlocked = !authContext.success && authContext.reason === 'seat_limit'; @@ -59,6 +64,7 @@ export default async function Page(props: { inviteRole={invite?.role ?? null} inviteInvalid={hasInvitedParam(searchParams.invited) && invite === null} seatLimitBlocked={seatLimitBlocked} + accountLinkHelpText={accountLinkHelpText} /> ); } diff --git a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts index b6bc24a51..cf5c48db1 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts @@ -8,6 +8,7 @@ const { exchangeCodeForTokensMock, getClientInformationMock, getMcpIntegrationMock, + getMcpIntegrationDefaultDisabledToolsMock, getMcpIntegrationOauthEndpointsMock, hydrateLinearMcpConnectionAfterOauthMock, isDeploymentScopedMcpIntegrationMock, @@ -15,6 +16,8 @@ const { loggerErrorMock, loggerWarnMock, mcpConnectionsFindFirstMock, + deploymentEnablementOnConflictMock, + deploymentEnablementValuesMock, storeTokensMock, updateAuthStatusMock, } = vi.hoisted(() => ({ @@ -25,6 +28,7 @@ const { exchangeCodeForTokensMock: vi.fn(), getClientInformationMock: vi.fn(), getMcpIntegrationMock: vi.fn(), + getMcpIntegrationDefaultDisabledToolsMock: vi.fn(), getMcpIntegrationOauthEndpointsMock: vi.fn(), hydrateLinearMcpConnectionAfterOauthMock: vi.fn(), isDeploymentScopedMcpIntegrationMock: vi.fn(), @@ -32,6 +36,8 @@ const { loggerErrorMock: vi.fn(), loggerWarnMock: vi.fn(), mcpConnectionsFindFirstMock: vi.fn(), + deploymentEnablementOnConflictMock: vi.fn().mockResolvedValue(undefined), + deploymentEnablementValuesMock: vi.fn(), storeTokensMock: vi.fn(), updateAuthStatusMock: vi.fn(), })); @@ -65,9 +71,7 @@ vi.mock('@roomote/db/server', () => ({ }, }, insert: vi.fn(() => ({ - values: vi.fn(() => ({ - onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), - })), + values: deploymentEnablementValuesMock, })), }, mcpConnections: { id: 'mcp_connections.id' }, @@ -86,6 +90,8 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('@roomote/types', () => ({ getMcpIntegration: getMcpIntegrationMock, + getMcpIntegrationDefaultDisabledTools: + getMcpIntegrationDefaultDisabledToolsMock, getMcpIntegrationOauthEndpoints: getMcpIntegrationOauthEndpointsMock, isDeploymentScopedMcpIntegration: isDeploymentScopedMcpIntegrationMock, isSelfServeMcpIntegration: isSelfServeMcpIntegrationMock, @@ -108,6 +114,9 @@ function buildRequest(query: string) { describe('GET /api/mcp-oauth/callback', () => { beforeEach(() => { vi.clearAllMocks(); + deploymentEnablementValuesMock.mockReturnValue({ + onConflictDoUpdate: deploymentEnablementOnConflictMock, + }); authorizeMock.mockResolvedValue({ success: true, userId: 'user-1', @@ -133,6 +142,7 @@ describe('GET /api/mcp-oauth/callback', () => { name: 'Linear', url: 'https://mcp.linear.app/mcp', }); + getMcpIntegrationDefaultDisabledToolsMock.mockReturnValue([]); getMcpIntegrationOauthEndpointsMock.mockReturnValue({ authorizationEndpoint: 'https://linear.app/oauth/authorize', tokenEndpoint: 'https://api.linear.app/oauth/token', @@ -291,6 +301,45 @@ describe('GET /api/mcp-oauth/callback', () => { ); }); + it('seeds Resend tool defaults without overwriting saved choices on reconnect', async () => { + mcpConnectionsFindFirstMock.mockResolvedValue({ + id: CONNECTION_ID, + mcpId: 'resend', + userId: null, + connectionRole: 'default', + }); + getMcpIntegrationMock.mockReturnValue({ + id: 'resend', + name: 'Resend', + url: 'https://mcp.resend.com/mcp', + }); + getMcpIntegrationOauthEndpointsMock.mockReturnValue({ + authorizationEndpoint: 'https://api.resend.com/oauth/authorize', + tokenEndpoint: 'https://api.resend.com/oauth/token', + registrationEndpoint: 'https://api.resend.com/oauth/register', + tokenEndpointAuthMethod: 'none', + }); + isDeploymentScopedMcpIntegrationMock.mockReturnValue(true); + getMcpIntegrationDefaultDisabledToolsMock.mockReturnValue([ + 'send-email', + 'create-contact', + ]); + + await GET(buildRequest('?code=auth-code&state=state-1')); + + expect(deploymentEnablementValuesMock).toHaveBeenCalledWith({ + mcpId: 'resend', + enabled: true, + enabledByUserId: 'user-1', + disabledTools: ['send-email', 'create-contact'], + }); + expect(deploymentEnablementOnConflictMock).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.not.objectContaining({ disabledTools: expect.anything() }), + }), + ); + }); + it('surfaces token exchange failures with a safe reason and stage', async () => { exchangeCodeForTokensMock.mockRejectedValueOnce( new Error('provider response omitted'), diff --git a/apps/web/src/app/api/mcp-oauth/callback/route.ts b/apps/web/src/app/api/mcp-oauth/callback/route.ts index 1a3e11550..fcf6410a5 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/route.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/route.ts @@ -9,6 +9,7 @@ import { } from '@roomote/db/server'; import { getMcpIntegration, + getMcpIntegrationDefaultDisabledTools, getMcpIntegrationOauthEndpoints, isDeploymentScopedMcpIntegration, isSelfServeMcpIntegration, @@ -339,12 +340,19 @@ export async function GET(request: NextRequest) { if (requiresOrgAdmin) { failureStage = 'deployment_enablement'; + const defaultDisabledTools = + getMcpIntegrationDefaultDisabledTools(integration); await db .insert(deploymentMcpEnablements) .values({ mcpId: integration.id, enabled: true, enabledByUserId: userId, + ...(defaultDisabledTools.length > 0 + ? { + disabledTools: [...defaultDisabledTools], + } + : {}), }) .onConflictDoUpdate({ target: deploymentMcpEnablements.mcpId, diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts index 2c4201233..ff17c784d 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts @@ -274,6 +274,45 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { expect(authUrl.searchParams.get('scope')).not.toContain('boards:write'); }); + it('requests full access for the deployment-scoped Resend connection', async () => { + mcpConnectionsFindFirstMock.mockResolvedValue({ + id: CONNECTION_ID, + mcpId: 'resend', + userId: null, + connectionRole: 'default', + }); + getMcpIntegrationMock.mockReturnValue({ + id: 'resend', + name: 'Resend', + url: 'https://mcp.resend.com/mcp', + }); + isDeploymentScopedMcpIntegrationMock.mockReturnValue(true); + getMcpIntegrationOauthEndpointsMock.mockReturnValue({ + authorizationEndpoint: 'https://api.resend.com/oauth/authorize', + tokenEndpoint: 'https://api.resend.com/oauth/token', + registrationEndpoint: 'https://api.resend.com/oauth/register', + tokenEndpointAuthMethod: 'none', + }); + getClientInformationMock.mockResolvedValue(undefined); + getMcpIntegrationOauthScopesMock.mockReturnValue(['full_access']); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ connectionId: CONNECTION_ID }), + }); + + expect(registerOAuthClientMock).toHaveBeenCalledWith( + 'https://api.resend.com/oauth/register', + expect.objectContaining({ + scope: 'full_access', + token_endpoint_auth_method: 'none', + }), + ); + const authUrl = new URL(response.headers.get('location')!); + expect(authUrl.searchParams.get('scope')).toBe('full_access'); + expect(discoverOAuthEndpointsMock).not.toHaveBeenCalled(); + expect(discoverOAuthProtectedResourceMetadataMock).not.toHaveBeenCalled(); + }); + it('stores the configured Linear OAuth client for the callback', async () => { getMcpIntegrationOauthEndpointsMock.mockReturnValue({ authorizationEndpoint: 'https://linear.app/oauth/authorize', diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts index 638f6d120..3460579d1 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts @@ -137,13 +137,15 @@ function getOAuthServerMetadataOverride( issuer: new URL(endpoints.authorizationEndpoint).origin, authorization_endpoint: endpoints.authorizationEndpoint, token_endpoint: endpoints.tokenEndpoint, + ...(endpoints.registrationEndpoint + ? { registration_endpoint: endpoints.registrationEndpoint } + : {}), scopes_supported: getMcpIntegrationOauthScopes(integration), response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], - token_endpoint_auth_methods_supported: [ - 'client_secret_post', - 'client_secret_basic', - ], + token_endpoint_auth_methods_supported: endpoints.tokenEndpointAuthMethod + ? [endpoints.tokenEndpointAuthMethod] + : ['client_secret_post', 'client_secret_basic'], }; } diff --git a/apps/web/src/components/layout/navbar/NavbarDrawer.client.test.tsx b/apps/web/src/components/layout/navbar/NavbarDrawer.client.test.tsx index 5611a75ac..551460a58 100644 --- a/apps/web/src/components/layout/navbar/NavbarDrawer.client.test.tsx +++ b/apps/web/src/components/layout/navbar/NavbarDrawer.client.test.tsx @@ -96,7 +96,7 @@ describe('NavbarDrawer', () => { .getAllByRole('link') .map((link) => link.textContent?.trim()) .filter(Boolean), - ).toEqual(['Home', 'Automations', 'Tasks', 'Analytics', 'Settings']); + ).toEqual(['Home', 'Tasks', 'Automations', 'Analytics', 'Settings']); expect( screen.queryByRole('button', { name: /support/i }), ).not.toBeInTheDocument(); diff --git a/apps/web/src/components/layout/navigation-items.test.ts b/apps/web/src/components/layout/navigation-items.test.ts index 0a75cca3a..6d8aab869 100644 --- a/apps/web/src/components/layout/navigation-items.test.ts +++ b/apps/web/src/components/layout/navigation-items.test.ts @@ -1,13 +1,13 @@ import { getVisiblePrimaryNavItems } from './navigation-items'; describe('getVisiblePrimaryNavItems', () => { - it('places automations before task history for admins', () => { + it('places task history before automations for admins', () => { const items = getVisiblePrimaryNavItems({ isAdmin: true }); expect(items.map((item) => item.href)).toEqual([ '/', - '/automations', '/tasks', + '/automations', '/analytics', ]); }); diff --git a/apps/web/src/components/layout/navigation-items.ts b/apps/web/src/components/layout/navigation-items.ts index a6db4ddd2..da059f972 100644 --- a/apps/web/src/components/layout/navigation-items.ts +++ b/apps/web/src/components/layout/navigation-items.ts @@ -22,6 +22,14 @@ const PRIMARY_NAV_ITEMS: PrimaryNavItem[] = [ matchExact: true, matchPaths: ['/'], }, + { + icon: Rows4, + href: '/tasks', + label: 'Tasks', + description: 'View current and past tasks', + matchExact: false, + matchPaths: ['/tasks', '/cloud-agents'], + }, { icon: Zap, href: '/automations', @@ -31,14 +39,6 @@ const PRIMARY_NAV_ITEMS: PrimaryNavItem[] = [ matchPaths: ['/automations'], adminOnly: true, }, - { - icon: Rows4, - href: '/tasks', - label: 'Tasks', - description: 'View current and past tasks', - matchExact: false, - matchPaths: ['/tasks', '/cloud-agents'], - }, { icon: ChartColumnIncreasing, href: '/analytics', diff --git a/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx b/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx index e43277b6d..34e2aa256 100644 --- a/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx +++ b/apps/web/src/components/layout/side-nav/SideNav.client.test.tsx @@ -505,14 +505,14 @@ describe('SideNav quick access tasks', () => { expect(screen.getByTestId('nav-/analytics')).toBeInTheDocument(); }); - it('shows automations before task history for admins', () => { + it('shows task history before automations for admins', () => { render(); const automations = screen.getByTestId('nav-/automations'); const tasks = screen.getByTestId('nav-/tasks'); expect(automations.compareDocumentPosition(tasks)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING, + Node.DOCUMENT_POSITION_PRECEDING, ); }); diff --git a/apps/web/src/components/settings/AccountLinkHelpSection.tsx b/apps/web/src/components/settings/AccountLinkHelpSection.tsx new file mode 100644 index 000000000..4844c82aa --- /dev/null +++ b/apps/web/src/components/settings/AccountLinkHelpSection.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { Section } from '@/components/settings'; +import { + AlertCircle, + Button, + Label, + LucideLink, + Skeleton, + Textarea, +} from '@/components/system'; +import { useTRPC } from '@/trpc/client'; + +export function AccountLinkHelpSection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const queryKey = trpc.accessPolicy.accountLinkHelp.queryKey(); + const settingsQuery = useQuery( + trpc.accessPolicy.accountLinkHelp.queryOptions(), + ); + const [value, setValue] = useState(''); + const [savedValue, setSavedValue] = useState(''); + const isDirty = value !== savedValue; + + const updateMutation = useMutation( + trpc.accessPolicy.setAccountLinkHelp.mutationOptions({ + onSuccess: (result) => { + queryClient.setQueryData(queryKey, result); + const nextValue = result.helpText ?? ''; + setValue(nextValue); + setSavedValue(nextValue); + toast.success('Account linking help saved.'); + }, + onError: (error) => toast.error(error.message), + onSettled: () => + queryClient.invalidateQueries({ + queryKey, + }), + }), + ); + + const serverValue = settingsQuery.data?.helpText ?? ''; + + useEffect(() => { + if (settingsQuery.data && !isDirty) { + setValue(serverValue); + setSavedValue(serverValue); + } + }, [isDirty, serverValue, settingsQuery.data]); + + const footer = + !isDirty && !updateMutation.isPending ? undefined : ( + <> + + + + ); + + return ( +
+

+ Add deployment-specific help when Roomote asks someone to link an + account before starting work, such as how to request an invite. This + appears in source-control comments and Discord and Telegram prompts. +

+ {settingsQuery.isPending ? ( + + ) : settingsQuery.isError ? ( +
+ +

Failed to load account linking help.

+
+ ) : ( +
+ +