From b33016e4c6dcd18a6ff8fe442ec6ffa81d48d24b Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 10:44:37 +0100
Subject: [PATCH 01/13] fix: suppress Discord release link embeds (#1081)
Co-authored-by: Roomote
---
scripts/release/__tests__/discord-release.test.mjs | 1 +
scripts/release/lib.mjs | 1 +
2 files changed, 2 insertions(+)
diff --git a/scripts/release/__tests__/discord-release.test.mjs b/scripts/release/__tests__/discord-release.test.mjs
index 4ff56a01c..d43d90e96 100644
--- a/scripts/release/__tests__/discord-release.test.mjs
+++ b/scripts/release/__tests__/discord-release.test.mjs
@@ -24,6 +24,7 @@ describe('Discord release announcement', () => {
});
assert.equal(payload.username, 'Roomote Releases');
+ assert.equal(payload.flags, 4);
assert.deepEqual(payload.allowed_mentions, { parse: [] });
assert.equal(
payload.content,
diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs
index d458ccbf6..a7e5eb363 100644
--- a/scripts/release/lib.mjs
+++ b/scripts/release/lib.mjs
@@ -246,6 +246,7 @@ export function buildDiscordReleasePayload(release) {
return {
username: 'Roomote Releases',
content,
+ flags: 1 << 2,
allowed_mentions: { parse: [] },
};
}
From c0f66876300e40dd604b2959f9c16547a04f0988 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 11:08:04 +0100
Subject: [PATCH 02/13] [Feat] Call Roomote from chat reactions (#1034)
* feat: call Roomote from thread reactions
* fix: tighten reaction-trigger provider behavior
* fix: prevent automation category label clipping
* fix: filter emoji automation by category
* fix: address reaction trigger review findings
* fix: scope reaction dedupe to gateway sessions
---------
Co-authored-by: Roomote
---
.../handlers/call-roomote-via-emoji.test.ts | 66 ++++++++
.../src/handlers/call-roomote-via-emoji.ts | 36 +++++
.../handlers/discord/__tests__/index.test.ts | 133 +++++++++++++++
.../__tests__/task-orchestration.test.ts | 75 +++++++++
.../discord/__tests__/thread-context.test.ts | 49 ++++++
apps/api/src/handlers/discord/index.ts | 118 +++++++++++++-
.../handlers/discord/task-orchestration.ts | 31 +++-
.../src/handlers/discord/thread-context.ts | 25 ++-
.../events/reactions-emoji-trigger.test.ts | 124 ++++++++++++++
.../src/handlers/slack/events/reactions.ts | 88 +++++++---
.../handlers/teams/__tests__/index.test.ts | 71 ++++++++
apps/api/src/handlers/teams/index.ts | 46 +++++-
apps/discord-gateway/src/dispatch.test.ts | 84 ++++++++++
apps/discord-gateway/src/dispatch.ts | 41 ++++-
.../src/gateway-resume-store.test.ts | 29 ++++
.../src/gateway-resume-store.ts | 12 ++
.../src/gateway-session.test.ts | 57 ++++++-
apps/discord-gateway/src/gateway-session.ts | 4 +
apps/discord-gateway/src/inbound-queue.ts | 5 +-
apps/docs/automations.mdx | 25 +++
.../AutomationsSettings.client.test.tsx | 23 +++
...AutomationsSettings.render.client.test.tsx | 27 ++++
.../automations/AutomationsSettings.tsx | 152 +++++++++++++++++-
.../settings/automations/formState.ts | 15 ++
.../src/components/system/primitives/icons.ts | 1 +
.../__tests__/settings-update-discord.test.ts | 24 +++
.../commands/automations/settings-update.ts | 36 +++++
.../src/trpc/commands/automations/types.ts | 6 +
apps/web/src/trpc/routers/_app.ts | 14 ++
packages/communication/package.json | 1 +
.../src/__tests__/reaction-emoji.test.ts | 28 ++++
packages/communication/src/discord-event.ts | 60 ++++++-
packages/communication/src/index.ts | 1 +
packages/communication/src/reaction-emoji.ts | 56 +++++++
packages/communication/src/teams-activity.ts | 22 +++
packages/db/src/lib/automations.test.ts | 32 ++++
packages/db/src/lib/automations.ts | 10 ++
packages/db/src/types.ts | 3 +
packages/types/src/background-agents.ts | 1 +
39 files changed, 1582 insertions(+), 49 deletions(-)
create mode 100644 apps/api/src/handlers/call-roomote-via-emoji.test.ts
create mode 100644 apps/api/src/handlers/call-roomote-via-emoji.ts
create mode 100644 apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts
create mode 100644 packages/communication/src/__tests__/reaction-emoji.test.ts
create mode 100644 packages/communication/src/reaction-emoji.ts
diff --git a/apps/api/src/handlers/call-roomote-via-emoji.test.ts b/apps/api/src/handlers/call-roomote-via-emoji.test.ts
new file mode 100644
index 000000000..4ceddf1bf
--- /dev/null
+++ b/apps/api/src/handlers/call-roomote-via-emoji.test.ts
@@ -0,0 +1,66 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const getAutomationRuntime = vi.hoisted(() => vi.fn());
+
+vi.mock('@roomote/db/server', () => ({
+ getAutomationRuntime,
+}));
+
+import {
+ CALL_ROOMOTE_VIA_EMOJI_PROMPT,
+ getCallRoomoteViaEmojiConfiguration,
+} from './call-roomote-via-emoji';
+
+describe('Call Roomote via emoji configuration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('uses the exact default prompt when no instructions are configured', async () => {
+ getAutomationRuntime.mockResolvedValue({
+ enabled: true,
+ instructions: null,
+ settings: { emoji: ':white_check_mark:' },
+ });
+
+ await expect(getCallRoomoteViaEmojiConfiguration('✅')).resolves.toEqual({
+ emoji: ':white_check_mark:',
+ prompt: 'Act on this',
+ });
+ expect(CALL_ROOMOTE_VIA_EMOJI_PROMPT).toBe('Act on this');
+ });
+
+ it('appends configured instructions after the default prompt', async () => {
+ getAutomationRuntime.mockResolvedValue({
+ enabled: true,
+ instructions: 'Prioritize safety.',
+ settings: { emoji: 'white_check_mark' },
+ });
+
+ await expect(
+ getCallRoomoteViaEmojiConfiguration('white_check_mark'),
+ ).resolves.toMatchObject({
+ prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.',
+ });
+ });
+
+ it('ignores disabled and non-matching reactions', async () => {
+ getAutomationRuntime.mockResolvedValue({
+ enabled: false,
+ instructions: null,
+ settings: { emoji: 'eyes' },
+ });
+ await expect(
+ getCallRoomoteViaEmojiConfiguration('eyes'),
+ ).resolves.toBeNull();
+
+ getAutomationRuntime.mockResolvedValue({
+ enabled: true,
+ instructions: null,
+ settings: { emoji: 'eyes' },
+ });
+ await expect(
+ getCallRoomoteViaEmojiConfiguration('fire'),
+ ).resolves.toBeNull();
+ });
+});
diff --git a/apps/api/src/handlers/call-roomote-via-emoji.ts b/apps/api/src/handlers/call-roomote-via-emoji.ts
new file mode 100644
index 000000000..1df9ec0ec
--- /dev/null
+++ b/apps/api/src/handlers/call-roomote-via-emoji.ts
@@ -0,0 +1,36 @@
+import { reactionEmojiMatches } from '@roomote/communication/reaction-emoji';
+import { getAutomationRuntime } from '@roomote/db/server';
+
+export const CALL_ROOMOTE_VIA_EMOJI_PROMPT = 'Act on this';
+
+type CallRoomoteViaEmojiConfiguration = {
+ emoji: string;
+ prompt: string;
+};
+
+export async function getCallRoomoteViaEmojiConfiguration(
+ receivedEmoji: string,
+): Promise {
+ const automation = await getAutomationRuntime('call_roomote_via_emoji');
+ const emoji =
+ typeof automation.settings.emoji === 'string'
+ ? automation.settings.emoji.trim()
+ : '';
+
+ if (
+ !automation.enabled ||
+ !emoji ||
+ !reactionEmojiMatches(emoji, receivedEmoji)
+ ) {
+ return null;
+ }
+
+ const instructions = automation.instructions?.trim();
+
+ return {
+ emoji,
+ prompt: instructions
+ ? `${CALL_ROOMOTE_VIA_EMOJI_PROMPT}\n\nAdditional instructions:\n${instructions}`
+ : CALL_ROOMOTE_VIA_EMOJI_PROMPT,
+ };
+}
diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts
index eeda20be2..f000d1984 100644
--- a/apps/api/src/handlers/discord/__tests__/index.test.ts
+++ b/apps/api/src/handlers/discord/__tests__/index.test.ts
@@ -53,6 +53,7 @@ const mocks = vi.hoisted(() => ({
fetchThreadHistory: vi.fn(),
shouldRouteUnmentioned: vi.fn(),
enqueueGatewayEvent: vi.fn(),
+ callViaEmojiConfig: vi.fn(),
}));
vi.mock('@roomote/redis', async (importOriginal) => {
@@ -140,6 +141,10 @@ vi.mock('../unmentioned-thread-reply.js', () => ({
shouldRouteUnmentionedDiscordThreadReplyToAgent: mocks.shouldRouteUnmentioned,
}));
+vi.mock('../../call-roomote-via-emoji.js', () => ({
+ getCallRoomoteViaEmojiConfiguration: mocks.callViaEmojiConfig,
+}));
+
vi.mock('../task-orchestration.js', () => ({
startNewDiscordTask: mocks.startNewTask,
}));
@@ -298,6 +303,7 @@ describe('Discord Gateway event handler', () => {
mocks.shouldRouteUnmentioned.mockResolvedValue(true);
mocks.queueMessage.mockResolvedValue(true);
mocks.enqueueGatewayEvent.mockResolvedValue({ jobId: 'event-message-1' });
+ mocks.callViaEmojiConfig.mockResolvedValue(null);
});
afterEach(() => {
@@ -357,6 +363,59 @@ describe('Discord Gateway event handler', () => {
expect(mocks.startNewTask).not.toHaveBeenCalled();
});
+ it('turns a configured reaction into a thread task entry', async () => {
+ mocks.callViaEmojiConfig.mockResolvedValue({
+ emoji: 'white_check_mark',
+ prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.',
+ });
+ mocks.getChannel.mockResolvedValue({
+ id: 'channel-1',
+ name: 'general',
+ type: 0,
+ guildId: 'guild-1',
+ });
+
+ const response = await postEvent({
+ eventId: 'channel-1:message-1:discord-user-1:white_check_mark',
+ eventType: 'MESSAGE_REACTION_ADD',
+ receivedAt: '2026-07-12T15:00:00.000Z',
+ payload: {
+ user_id: 'discord-user-1',
+ channel_id: 'channel-1',
+ message_id: 'message-1',
+ guild_id: 'guild-1',
+ emoji: { id: null, name: 'white_check_mark' },
+ member: {
+ user: { id: 'discord-user-1', username: 'matt' },
+ },
+ },
+ });
+
+ expect(response.status).toBe(200);
+ expect(mocks.channelAutoStart).not.toHaveBeenCalled();
+ expect(mocks.addReaction).toHaveBeenCalledWith({
+ channelId: 'channel-1',
+ messageId: 'message-1',
+ name: '👀',
+ });
+ expect(mocks.startNewTask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ requesterDiscordUserId: 'discord-user-1',
+ launchOwnerUserId: 'roomote-user-1',
+ queuedMessage: expect.objectContaining({
+ text: 'Act on this\n\nAdditional instructions:\nPrioritize safety.',
+ }),
+ metadata: expect.objectContaining({
+ communicationMessageId: 'message-1',
+ communicationAnchorMessageId: 'message-1',
+ }),
+ replyToMessageId: 'message-1',
+ replyToChannelId: 'channel-1',
+ contextThroughMessageId: 'message-1',
+ }),
+ );
+ });
+
it('rejects an invalid Gateway secret before claiming the event', async () => {
const response = await postEvent(envelope(message()), 'wrong-secret');
@@ -1976,6 +2035,80 @@ describe('Discord Gateway event handler', () => {
);
});
+ it('preserves a pending reaction target after account linking', async () => {
+ const eventId =
+ 'channel-1:message-target:discord-user-1:white_check_mark:42';
+ const originalEvent = {
+ eventId,
+ eventType: 'MESSAGE_CREATE' as const,
+ receivedAt: '2026-07-12T15:00:00.000Z',
+ reactionTarget: {
+ channelId: 'channel-1',
+ messageId: 'message-target',
+ },
+ payload: {
+ id: eventId,
+ channel_id: 'channel-1',
+ guild_id: 'guild-1',
+ content: '<@bot-1> Act on this',
+ author: { id: 'discord-user-1', username: 'matt' },
+ mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }],
+ attachments: [],
+ message_reference: {
+ message_id: 'message-target',
+ channel_id: 'channel-1',
+ },
+ },
+ };
+ mocks.consumeLinkCode.mockResolvedValue('roomote-user-1');
+ mocks.redisGetdel.mockResolvedValue(JSON.stringify(originalEvent));
+ mocks.getChannel.mockImplementation(async (channelId: string) =>
+ channelId === 'dm-1'
+ ? { id: 'dm-1', name: 'Direct message', type: 1 }
+ : {
+ id: 'channel-1',
+ guildId: 'guild-1',
+ name: 'general',
+ type: 0,
+ },
+ );
+ const interaction = {
+ id: 'interaction-link',
+ application_id: 'app-1',
+ type: 2,
+ token: 'interaction-token',
+ channel_id: 'dm-1',
+ user: { id: 'discord-user-1', username: 'matt' },
+ data: {
+ name: 'link',
+ type: 1,
+ options: [{ name: 'code', type: 3, value: 'link-abcdefghijklmnop' }],
+ },
+ };
+
+ const response = await postEvent(
+ envelope(interaction, 'INTERACTION_CREATE'),
+ );
+
+ expect(response.status).toBe(200);
+ expect(mocks.startNewTask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ communicationMessageId: 'message-target',
+ communicationAnchorMessageId: 'message-target',
+ }),
+ replyToMessageId: 'message-target',
+ replyToChannelId: 'channel-1',
+ contextThroughMessageId: 'message-target',
+ }),
+ );
+ expect(mocks.addReaction).toHaveBeenCalledWith({
+ channelId: 'channel-1',
+ messageId: 'message-target',
+ name: '👀',
+ });
+ });
+
it('requires /link in a DM without consuming the one-shot code', async () => {
mocks.getChannel.mockResolvedValue({
id: 'channel-1',
diff --git a/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts b/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts
index db0106314..0aa73881e 100644
--- a/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts
+++ b/apps/api/src/handlers/discord/__tests__/task-orchestration.test.ts
@@ -312,6 +312,81 @@ describe('startNewDiscordTask', () => {
expect(agentPrompt).not.toContain('@Roomote investigate the flaky build');
});
+ it('excludes attachments posted after a reacted-to message', async () => {
+ const file = (id: string) => ({
+ id,
+ name: `${id}.txt`,
+ mimeType: 'text/plain',
+ size: 12,
+ url: `https://cdn.discordapp.com/attachments/${id}.txt`,
+ });
+ const provider = {
+ fetchChannelMessages: vi.fn().mockResolvedValue({
+ messages: [
+ {
+ id: '100',
+ user: 'u-alice',
+ username: 'Alice',
+ text: 'Earlier context',
+ files: [file('before')],
+ },
+ {
+ id: '200',
+ user: 'u-alice',
+ username: 'Alice',
+ text: 'React to this',
+ files: [file('target')],
+ },
+ {
+ id: '300',
+ user: 'u-bob',
+ username: 'Bob',
+ text: 'Later context',
+ files: [file('after')],
+ },
+ ],
+ }),
+ };
+
+ await startNewDiscordTask({
+ provider: provider as never,
+ applicationId: 'application-1',
+ requesterDiscordUserId: 'discord-user-1',
+ launchOwnerUserId: 'user-1',
+ contextThroughMessageId: '200',
+ queuedMessage: {
+ provider: 'discord',
+ text: 'Act on this',
+ user: 'Matt',
+ userId: 'user-1',
+ ts: 'channel-1:200:discord-user-1:white_check_mark:42',
+ },
+ metadata: {
+ communicationProvider: 'discord',
+ communicationChannelId: 'channel-1',
+ communicationThreadId: 'thread-1',
+ communicationMessageId: '200',
+ },
+ channel: {
+ channelId: 'thread-1',
+ channelName: 'Task thread',
+ channelType: 11,
+ guildId: 'guild-1',
+ parentChannelId: 'channel-1',
+ isDirectMessage: false,
+ isThread: true,
+ },
+ });
+
+ expect(mocks.processAttachments).toHaveBeenCalledWith([
+ expect.objectContaining({ id: 'before' }),
+ expect.objectContaining({ id: 'target' }),
+ ]);
+ expect(mocks.processAttachments).not.toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ id: 'after' })]),
+ );
+ });
+
it('does not inherit prior thread context for /new (forceNewThread)', async () => {
const provider = {
fetchChannelMessages: vi.fn().mockResolvedValue({
diff --git a/apps/api/src/handlers/discord/__tests__/thread-context.test.ts b/apps/api/src/handlers/discord/__tests__/thread-context.test.ts
index 3a290f46d..1cd5f921b 100644
--- a/apps/api/src/handlers/discord/__tests__/thread-context.test.ts
+++ b/apps/api/src/handlers/discord/__tests__/thread-context.test.ts
@@ -296,6 +296,55 @@ describe('buildDiscordContinuationPrompt', () => {
expect(result.claimedMessageIds).toEqual(['100']);
});
+ it('orders synthetic reaction turns by their real target message', async () => {
+ const provider = {
+ fetchChannelMessages: vi.fn().mockResolvedValue({
+ messages: [
+ {
+ id: '100',
+ user: 'u-alice',
+ username: 'Alice',
+ text: 'Please investigate this failure',
+ },
+ {
+ id: '200',
+ user: 'u-bob',
+ username: 'Bob',
+ text: 'This happened later',
+ },
+ ],
+ }),
+ fetchMessage: vi.fn().mockResolvedValue({
+ provider: 'discord',
+ id: '100',
+ user: 'u-alice',
+ username: 'Alice',
+ text: 'Please investigate this failure',
+ channelId: 'channel-1',
+ fileCount: 0,
+ }),
+ };
+
+ const result = await buildDiscordContinuationPrompt({
+ provider: provider as never,
+ channelId: 'channel-1',
+ replyToMessageId: '100',
+ contextThroughMessageId: '100',
+ queuedMessage: {
+ provider: 'discord',
+ text: 'Act on this',
+ user: 'Matt',
+ ts: 'channel-1:100:u-matt:white_check_mark',
+ },
+ });
+
+ expect(result.message.formattedPrompt).toContain(
+ 'Alice: Please investigate this failure',
+ );
+ expect(result.message.formattedPrompt).not.toContain('This happened later');
+ expect(result.message.formattedPrompt).toContain('Act on this');
+ });
+
it('includes an explicit replied-to human message even when already delivered', async () => {
deliveryMocks.claim.mockResolvedValue([]);
const provider = {
diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts
index 8730ed824..3160baf32 100644
--- a/apps/api/src/handlers/discord/index.ts
+++ b/apps/api/src/handlers/discord/index.ts
@@ -6,6 +6,7 @@ import {
getDiscordInteractionCreate,
getDiscordInteractionUser,
getDiscordMessageCreate,
+ getDiscordReactionAdd,
isDiscordBotMentioned,
isDiscordTaskEntryEvent,
parseDiscordGatewayEvent,
@@ -38,6 +39,7 @@ import {
} from '@roomote/sdk/server';
import { apiLogger } from '../../logging.js';
+import { getCallRoomoteViaEmojiConfiguration } from '../call-roomote-via-emoji.js';
import { syncActingUserForInboundMessage } from '../tasks/acting-user-sync.js';
import {
attachOutOfBandContextToCommunicationMessage,
@@ -228,7 +230,86 @@ async function refreshDiscordUserMappingBestEffort(input: {
}
}
-async function processDiscordGatewayEvent(event: DiscordGatewayEvent) {
+type DiscordReactionTarget = { channelId: string; messageId: string };
+
+function getPersistedDiscordReactionTarget(
+ event: DiscordGatewayEvent,
+): DiscordReactionTarget | undefined {
+ const value = event.reactionTarget;
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return undefined;
+ }
+ const target = value as Record;
+ return typeof target.channelId === 'string' &&
+ typeof target.messageId === 'string'
+ ? { channelId: target.channelId, messageId: target.messageId }
+ : undefined;
+}
+
+async function processDiscordGatewayEvent(
+ event: DiscordGatewayEvent,
+ options: {
+ reactionTarget?: DiscordReactionTarget;
+ } = {},
+) {
+ const reactionTarget =
+ options.reactionTarget ?? getPersistedDiscordReactionTarget(event);
+ const reaction = getDiscordReactionAdd(event);
+ if (reaction) {
+ const resolved = await resolveDiscordProvider();
+ if (reaction.user_id === resolved.botUserId || !reaction.emoji.name) {
+ return { ok: true, ignored: 'bot_or_missing_reaction' };
+ }
+
+ const configuration = await getCallRoomoteViaEmojiConfiguration(
+ reaction.emoji.name,
+ );
+ if (!configuration) {
+ return { ok: true, ignored: 'reaction_not_configured' };
+ }
+
+ const author = reaction.member?.user ?? {
+ id: reaction.user_id,
+ username: `Discord user ${reaction.user_id}`,
+ };
+ return processDiscordGatewayEvent(
+ {
+ eventId: event.eventId,
+ eventType: 'MESSAGE_CREATE',
+ receivedAt: event.receivedAt,
+ reactionTarget: {
+ channelId: reaction.channel_id,
+ messageId: reaction.message_id,
+ },
+ payload: {
+ id: event.eventId,
+ channel_id: reaction.channel_id,
+ ...(reaction.guild_id ? { guild_id: reaction.guild_id } : {}),
+ content: `<@${resolved.botUserId}> ${configuration.prompt}`,
+ author,
+ mentions: [
+ {
+ id: resolved.botUserId,
+ username: 'Roomote',
+ bot: true,
+ },
+ ],
+ attachments: [],
+ message_reference: {
+ message_id: reaction.message_id,
+ channel_id: reaction.channel_id,
+ },
+ },
+ },
+ {
+ reactionTarget: {
+ channelId: reaction.channel_id,
+ messageId: reaction.message_id,
+ },
+ },
+ );
+ }
+
const interaction = getDiscordInteractionCreate(event);
const message = getDiscordMessageCreate(event);
if (interaction?.type === 3) {
@@ -256,10 +337,14 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) {
});
const metadata = discordMetadataForChannel({
channel,
- messageId: event.payload.id,
+ messageId: reactionTarget?.messageId ?? event.eventId,
// Only a real channel message provides an anchor for the task thread;
// interactions (slash commands, buttons) do not.
- ...(message?.id ? { anchorMessageId: message.id } : {}),
+ ...(reactionTarget?.messageId
+ ? { anchorMessageId: reactionTarget.messageId }
+ : message?.id
+ ? { anchorMessageId: message.id }
+ : {}),
});
if (interaction?.type === 3) {
@@ -276,7 +361,7 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) {
// Auto-respond channels run first, mirroring Slack: a message in a
// configured channel — mentioned or not, bot- or human-authored — is
// consumed here and never reaches the mention/task-entry gating below.
- if (message && !interaction) {
+ if (message && !interaction && !reactionTarget) {
const handledAsChannelAutoStart = await maybeHandleDiscordChannelAutoStart({
event,
message,
@@ -497,7 +582,11 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) {
channel,
discordUserId: sender.id,
...(interaction ? { interaction: interactionReplyContext(event) } : {}),
- ...(message?.id ? { replyToMessageId: message.id } : {}),
+ ...(reactionTarget?.messageId
+ ? { replyToMessageId: reactionTarget.messageId }
+ : message?.id
+ ? { replyToMessageId: message.id }
+ : {}),
});
return {
ok: true,
@@ -701,6 +790,9 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) {
: {}),
botUserId: resolved.botUserId,
queuedMessage,
+ ...(reactionTarget?.messageId
+ ? { contextThroughMessageId: reactionTarget.messageId }
+ : {}),
...(message?.message_reference?.message_id
? {
replyToMessageId: message.message_reference.message_id,
@@ -760,7 +852,11 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) {
await releaseDiscordContinuationClaim(continuationClaim);
throw error;
}
- await setLatestInboundMessageId('discord', activeRun.id, queuedMessage.ts);
+ await setLatestInboundMessageId(
+ 'discord',
+ activeRun.id,
+ reactionTarget?.messageId ?? queuedMessage.ts,
+ );
// Match Slack: eyes is an intake-only platform ack. Active follow-ups are
// already durable once queued; agents may still react when turn policy allows.
return { ok: true, queued: true, runId: activeRun.id };
@@ -798,6 +894,9 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) {
: {}),
botUserId: resolved.botUserId,
queuedMessage,
+ ...(reactionTarget?.messageId
+ ? { contextThroughMessageId: reactionTarget.messageId }
+ : {}),
...(message?.message_reference?.message_id
? {
replyToMessageId: message.message_reference.message_id,
@@ -884,8 +983,8 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) {
if (message?.id) {
try {
await resolved.provider.addReaction({
- channelId: channel.channelId,
- messageId: message.id,
+ channelId: reactionTarget?.channelId ?? channel.channelId,
+ messageId: reactionTarget?.messageId ?? message.id,
name: '👀',
});
intakeAckPinned = true;
@@ -918,6 +1017,9 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) {
: {}),
}
: {}),
+ ...(reactionTarget?.messageId
+ ? { contextThroughMessageId: reactionTarget.messageId }
+ : {}),
});
} catch (error) {
if (isDeploymentReadOnlyError(error)) {
diff --git a/apps/api/src/handlers/discord/task-orchestration.ts b/apps/api/src/handlers/discord/task-orchestration.ts
index 1fa72856e..c1aff1bcd 100644
--- a/apps/api/src/handlers/discord/task-orchestration.ts
+++ b/apps/api/src/handlers/discord/task-orchestration.ts
@@ -37,6 +37,17 @@ import {
type DiscordThreadHistoryMessage,
} from './thread-context.js';
+function compareDiscordMessageIds(left: string, right: string): number {
+ try {
+ const leftId = BigInt(left);
+ const rightId = BigInt(right);
+ if (leftId === rightId) return 0;
+ return leftId < rightId ? -1 : 1;
+ } catch {
+ return left.localeCompare(right);
+ }
+}
+
/**
* Soft-clear the MESSAGE_CREATE intake 👀 when a path ends without a worker.
* Platform answers and auto-start skips never hit onStart cleanup.
@@ -99,6 +110,8 @@ export async function startNewDiscordTask(input: {
replyToMessageId?: string;
/** Discord `message_reference.channel_id` when present. */
replyToChannelId?: string;
+ /** Real Discord message included as the endpoint of synthetic reaction context. */
+ contextThroughMessageId?: string;
}) {
const existingRun = await findCommunicationTaskRunBySourceEvent({
provider: 'discord',
@@ -176,11 +189,18 @@ export async function startNewDiscordTask(input: {
text: input.queuedMessage.text,
attachments: [],
};
- const historyWithTrigger = history.some(
+ const contextThroughMessageId = input.contextThroughMessageId;
+ const contextHistory = contextThroughMessageId
+ ? history.filter(
+ (message) =>
+ compareDiscordMessageIds(message.id, contextThroughMessageId) <= 0,
+ )
+ : history;
+ const historyWithTrigger = contextHistory.some(
(message) => message.id === triggeringMessage.id,
)
- ? history
- : [...history, triggeringMessage];
+ ? contextHistory
+ : [...contextHistory, triggeringMessage];
// Full thread launches get the reconstructed transcript; top-level channel
// reply launches only pass the explicit reply target + current turn.
const includeReplyContext =
@@ -198,7 +218,7 @@ export async function startNewDiscordTask(input: {
},
];
const historyAttachments = includeReplyContext
- ? toDiscordAttachmentsFromHistory(history, {
+ ? toDiscordAttachmentsFromHistory(contextHistory, {
excludeMessageId: input.queuedMessage.ts,
})
: [];
@@ -220,7 +240,8 @@ export async function startNewDiscordTask(input: {
const threadContext = includeReplyContext
? formatDiscordThreadContext({
messages: historyWithTrigger,
- currentMessageId: input.queuedMessage.ts,
+ currentMessageId: contextThroughMessageId ?? input.queuedMessage.ts,
+ ...(contextThroughMessageId ? { includeCurrentMessage: true } : {}),
})
: undefined;
const agentPromptPrefix = input.channelAutoStart?.agentPromptPrefix?.trim();
diff --git a/apps/api/src/handlers/discord/thread-context.ts b/apps/api/src/handlers/discord/thread-context.ts
index bd892f31c..4c75ee991 100644
--- a/apps/api/src/handlers/discord/thread-context.ts
+++ b/apps/api/src/handlers/discord/thread-context.ts
@@ -73,10 +73,13 @@ function formatDiscordThreadContextEntry(
export function formatDiscordThreadContext(input: {
messages: DiscordThreadHistoryMessage[];
currentMessageId: string;
+ includeCurrentMessage?: boolean;
}): string | undefined {
const earlier = input.messages.filter(
(message) =>
- compareDiscordSnowflakes(message.id, input.currentMessageId) < 0 &&
+ (input.includeCurrentMessage
+ ? compareDiscordSnowflakes(message.id, input.currentMessageId) <= 0
+ : compareDiscordSnowflakes(message.id, input.currentMessageId) < 0) &&
messageHasThreadDeliveryContent(message),
);
if (earlier.length === 0) return undefined;
@@ -329,6 +332,11 @@ export async function buildDiscordContinuationPrompt(input: {
* lives in `channelId` / parent channel.
*/
replyToChannelId?: string;
+ /**
+ * Real Discord message that the synthetic current turn acts on. Include
+ * history through this message instead of ordering by the synthetic event id.
+ */
+ contextThroughMessageId?: string;
}): Promise {
const claimUndelivered = input.claimUndelivered !== false;
const [historyBase, repliedToMessage] = await Promise.all([
@@ -353,10 +361,16 @@ export async function buildDiscordContinuationPrompt(input: {
repliedTo: repliedToMessage,
});
+ const contextMessageId =
+ input.contextThroughMessageId ?? input.queuedMessage.ts;
+ const includeContextMessage = Boolean(input.contextThroughMessageId);
+ const isInContext = (messageId: string) =>
+ includeContextMessage
+ ? compareDiscordSnowflakes(messageId, contextMessageId) <= 0
+ : compareDiscordSnowflakes(messageId, contextMessageId) < 0;
const earlier = history.filter(
(message) =>
- compareDiscordSnowflakes(message.id, input.queuedMessage.ts) < 0 &&
- messageHasThreadDeliveryContent(message),
+ isInContext(message.id) && messageHasThreadDeliveryContent(message),
);
const ownBotEarlier = earlier.filter(
@@ -419,7 +433,7 @@ export async function buildDiscordContinuationPrompt(input: {
if (
repliedToMessage &&
messageHasThreadDeliveryContent(repliedToMessage) &&
- compareDiscordSnowflakes(repliedToMessage.id, input.queuedMessage.ts) < 0 &&
+ isInContext(repliedToMessage.id) &&
!(input.botUserId && repliedToMessage.botId === input.botUserId)
) {
claimedMessages = mergeDiscordRepliedToMessage({
@@ -466,7 +480,8 @@ export async function buildDiscordContinuationPrompt(input: {
});
const threadContext = formatDiscordThreadContext({
messages: threadContextMessages,
- currentMessageId: input.queuedMessage.ts,
+ currentMessageId: contextMessageId,
+ ...(includeContextMessage ? { includeCurrentMessage: true } : {}),
});
const replyingToBlock =
diff --git a/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts b/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts
new file mode 100644
index 000000000..ea3bd55ed
--- /dev/null
+++ b/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ getConfiguration: vi.fn(),
+ handleMessage: vi.fn(),
+}));
+
+vi.mock('../../call-roomote-via-emoji.js', () => ({
+ getCallRoomoteViaEmojiConfiguration: mocks.getConfiguration,
+}));
+
+vi.mock('./message-entry.js', () => ({
+ handleMessageOrAppMentionEvent: mocks.handleMessage,
+}));
+
+import {
+ handleReactionAddedEvent,
+ maybeCallRoomoteViaEmoji,
+} from './reactions';
+
+describe('Slack emoji trigger', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('turns a configured reaction into an app mention in the target thread', async () => {
+ mocks.getConfiguration.mockResolvedValue({
+ emoji: 'white_check_mark',
+ prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.',
+ });
+ const getMessage = vi.fn().mockResolvedValue({
+ ts: '1710000000.000100',
+ thread_ts: '1710000000.000000',
+ text: 'Please investigate this.',
+ });
+ const context = {
+ teamId: 'T1',
+ slackInstallation: { botUserId: 'UROOMOTE' },
+ slack: { getMessage },
+ };
+ const event = {
+ type: 'reaction_added' as const,
+ user: 'U1',
+ reaction: 'white_check_mark',
+ item: {
+ type: 'message' as const,
+ channel: 'C1',
+ ts: '1710000000.000100',
+ },
+ event_ts: '1710000001.000000',
+ };
+
+ await expect(
+ maybeCallRoomoteViaEmoji({
+ context: context as never,
+ event,
+ }),
+ ).resolves.toBe(true);
+
+ expect(mocks.handleMessage).toHaveBeenCalledWith({
+ context,
+ event: {
+ type: 'app_mention',
+ channel: 'C1',
+ user: 'U1',
+ text: '<@UROOMOTE> Act on this\n\nAdditional instructions:\nPrioritize safety.',
+ ts: '1710000000.000100',
+ thread_ts: '1710000000.000000',
+ },
+ });
+ });
+
+ it('does nothing when the reaction is not configured', async () => {
+ mocks.getConfiguration.mockResolvedValue(null);
+
+ await expect(
+ maybeCallRoomoteViaEmoji({
+ context: {} as never,
+ event: {
+ type: 'reaction_added',
+ user: 'U1',
+ reaction: 'eyes',
+ item: { type: 'message', channel: 'C1', ts: '1' },
+ event_ts: '2',
+ },
+ }),
+ ).resolves.toBe(false);
+ });
+
+ it('gives the configured trigger precedence over thumbs-up suggestion actions', async () => {
+ mocks.getConfiguration.mockResolvedValue({
+ emoji: 'thumbsup',
+ prompt: 'Act on this',
+ });
+ const context = {
+ teamId: 'T1',
+ slackInstallation: { botUserId: 'UROOMOTE' },
+ slack: {
+ getMessage: vi.fn().mockResolvedValue({
+ ts: '1710000000.000100',
+ text: 'A suggested task.',
+ }),
+ },
+ };
+
+ await handleReactionAddedEvent({
+ context: context as never,
+ event: {
+ type: 'reaction_added',
+ user: 'U1',
+ reaction: 'thumbsup',
+ item: { type: 'message', channel: 'C1', ts: '1710000000.000100' },
+ event_ts: '1710000001.000000',
+ },
+ });
+
+ expect(mocks.handleMessage).toHaveBeenCalledTimes(1);
+ expect(mocks.handleMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.objectContaining({ text: '<@UROOMOTE> Act on this' }),
+ }),
+ );
+ });
+});
diff --git a/apps/api/src/handlers/slack/events/reactions.ts b/apps/api/src/handlers/slack/events/reactions.ts
index 44d3c8b6a..2c33b5be0 100644
--- a/apps/api/src/handlers/slack/events/reactions.ts
+++ b/apps/api/src/handlers/slack/events/reactions.ts
@@ -33,6 +33,7 @@ import {
} from '@roomote/db/server';
import { apiLogger } from '../../../logging.js';
+import { getCallRoomoteViaEmojiConfiguration } from '../../call-roomote-via-emoji.js';
import { cancelOrphanedWorkItemRunBestEffort } from '../../tasks/orphaned-work-item-run.js';
import {
SLACK_SETUP_SUGGESTION_LOCK_PREFIX,
@@ -47,6 +48,44 @@ import {
type TaskSuggestionReactionLaunchResult,
type TaskSuggestionReactionState,
} from './task-suggestion-reaction-contention.js';
+import { handleMessageOrAppMentionEvent } from './message-entry.js';
+
+export async function maybeCallRoomoteViaEmoji(params: {
+ context: SlackWebhookContext;
+ event: SlackReactionAddedEvent;
+}): Promise {
+ const configuration = await getCallRoomoteViaEmojiConfiguration(
+ params.event.reaction,
+ );
+ if (!configuration) {
+ return false;
+ }
+
+ const targetMessage = await params.context.slack.getMessage({
+ channel: params.event.item.channel,
+ messageTs: params.event.item.ts,
+ });
+ if (!targetMessage) {
+ apiLogger.warn(
+ `[SlackWebhook] Could not resolve emoji summon target ${params.event.item.channel}:${params.event.item.ts}`,
+ );
+ return true;
+ }
+
+ await handleMessageOrAppMentionEvent({
+ context: params.context,
+ event: {
+ type: 'app_mention',
+ channel: params.event.item.channel,
+ user: params.event.user,
+ text: `<@${params.context.slackInstallation.botUserId}> ${configuration.prompt}`,
+ ts: params.event.item.ts,
+ thread_ts: targetMessage.thread_ts ?? targetMessage.ts,
+ },
+ });
+
+ return true;
+}
async function postSuggestionLaunchFailureMessage(params: {
slack: SlackNotifier;
@@ -704,7 +743,6 @@ export async function handleReactionAddedEvent(params: {
event: SlackReactionAddedEvent;
}): Promise {
const { context, event } = params;
- const reactionNames = await resolveSlackReactionNames();
const isMessageItem = event.item.type === 'message';
if (!isMessageItem) {
@@ -718,33 +756,39 @@ export async function handleReactionAddedEvent(params: {
return;
}
- if (!isThumbsUpReaction(event.reaction)) {
+ if (await maybeCallRoomoteViaEmoji({ context, event })) {
return;
}
- apiLogger.debug(
- `[SetupSuggestionLifecycle] Processing thumbs-up reaction team=${context.teamId} channel=${event.item.channel} messageTs=${event.item.ts} reaction=${event.reaction} user=${event.user}`,
- );
- const setupSuggestionLockKey = `${SLACK_SETUP_SUGGESTION_LOCK_PREFIX}${event.item.channel}:${event.item.ts}`;
- const setupSuggestionHandled = await launchTaskSuggestionTaskWithContention({
- lockKey: setupSuggestionLockKey,
- channelId: event.item.channel,
- messageTs: event.item.ts,
- launch: () =>
- launchTaskSuggestionTaskFromReaction({
- teamId: context.teamId,
- slack: context.slack,
- reactionEvent: event,
- ackEmoji: reactionNames.ackEmoji,
- completionEmoji: reactionNames.completionEmoji,
- }),
- });
+ const reactionNames = await resolveSlackReactionNames();
- if (setupSuggestionHandled) {
+ if (isThumbsUpReaction(event.reaction)) {
apiLogger.debug(
- `[SlackWebhook] Setup suggestion reaction handled for ${event.item.channel}:${event.item.ts}`,
+ `[SetupSuggestionLifecycle] Processing thumbs-up reaction team=${context.teamId} channel=${event.item.channel} messageTs=${event.item.ts} reaction=${event.reaction} user=${event.user}`,
);
- return;
+ const setupSuggestionLockKey = `${SLACK_SETUP_SUGGESTION_LOCK_PREFIX}${event.item.channel}:${event.item.ts}`;
+ const setupSuggestionHandled = await launchTaskSuggestionTaskWithContention(
+ {
+ lockKey: setupSuggestionLockKey,
+ channelId: event.item.channel,
+ messageTs: event.item.ts,
+ launch: () =>
+ launchTaskSuggestionTaskFromReaction({
+ teamId: context.teamId,
+ slack: context.slack,
+ reactionEvent: event,
+ ackEmoji: reactionNames.ackEmoji,
+ completionEmoji: reactionNames.completionEmoji,
+ }),
+ },
+ );
+
+ if (setupSuggestionHandled) {
+ apiLogger.debug(
+ `[SlackWebhook] Setup suggestion reaction handled for ${event.item.channel}:${event.item.ts}`,
+ );
+ return;
+ }
}
apiLogger.debug(
diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts
index 25fc29d09..431272dee 100644
--- a/apps/api/src/handlers/teams/__tests__/index.test.ts
+++ b/apps/api/src/handlers/teams/__tests__/index.test.ts
@@ -35,6 +35,7 @@ const {
withContentionMock,
claimPendingOutOfBandMock,
releaseClaimedOutOfBandMock,
+ callViaEmojiConfigMock,
} = vi.hoisted(() => ({
authAccountsFindFirstMock: vi.fn(),
authAccountsFindManyMock: vi.fn(),
@@ -89,6 +90,7 @@ const {
withContentionMock: vi.fn(),
claimPendingOutOfBandMock: vi.fn(),
releaseClaimedOutOfBandMock: vi.fn(),
+ callViaEmojiConfigMock: vi.fn(),
}));
vi.mock('@roomote/env', () => ({
@@ -266,6 +268,10 @@ vi.mock('../unmentioned-thread-reply.js', () => ({
shouldRouteUnmentionedReplyMock,
}));
+vi.mock('../../call-roomote-via-emoji.js', () => ({
+ getCallRoomoteViaEmojiConfiguration: callViaEmojiConfigMock,
+}));
+
import { teams } from '../index';
function createApp() {
@@ -372,6 +378,7 @@ describe('Teams webhook handler', () => {
usersFindFirstMock.mockResolvedValue(null);
verifyBotFrameworkJwtMock.mockResolvedValue({ payload: {} });
shouldRouteUnmentionedReplyMock.mockResolvedValue(false);
+ callViaEmojiConfigMock.mockResolvedValue(null);
withContentionMock.mockImplementation(
async (
_key: string,
@@ -401,6 +408,70 @@ describe('Teams webhook handler', () => {
expect(insertMock).not.toHaveBeenCalled();
});
+ it('turns a configured reaction into a thread message', async () => {
+ callViaEmojiConfigMock.mockResolvedValue({
+ emoji: 'thumbsup',
+ prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.',
+ });
+
+ const response = await createApp().request('/teams', {
+ method: 'POST',
+ headers: {
+ authorization: 'Bearer valid-token',
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(
+ createTeamsActivity({
+ type: 'messageReaction',
+ id: 'reaction-1',
+ text: undefined,
+ entities: undefined,
+ replyToId: 'activity-root',
+ reactionsAdded: [{ type: 'like' }],
+ }),
+ ),
+ });
+
+ expect(response.status).toBe(200);
+ expect(queueCommunicationMessageMock).toHaveBeenCalledWith(
+ 'teams',
+ 77,
+ expect.objectContaining({
+ provider: 'teams',
+ text: 'Act on this Additional instructions: Prioritize safety.',
+ ts: 'reaction-1',
+ threadTs: 'activity-root',
+ }),
+ );
+ });
+
+ it('ignores reaction types outside the Teams native set', async () => {
+ const response = await createApp().request('/teams', {
+ method: 'POST',
+ headers: {
+ authorization: 'Bearer valid-token',
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(
+ createTeamsActivity({
+ type: 'messageReaction',
+ id: 'reaction-unsupported',
+ text: undefined,
+ entities: undefined,
+ replyToId: 'activity-root',
+ reactionsAdded: [{ type: 'white_check_mark' }],
+ }),
+ ),
+ });
+
+ await expect(response.json()).resolves.toEqual({
+ ok: true,
+ ignored: 'reaction_not_configured',
+ });
+ expect(callViaEmojiConfigMock).not.toHaveBeenCalled();
+ expect(queueCommunicationMessageMock).not.toHaveBeenCalled();
+ });
+
it('queues Teams message activities for matching active task runs', async () => {
teamsUserMappingFindFirstMock.mockResolvedValueOnce({
userId: 'mapped-user-1',
diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts
index 62d43a47c..91418324c 100644
--- a/apps/api/src/handlers/teams/index.ts
+++ b/apps/api/src/handlers/teams/index.ts
@@ -11,6 +11,7 @@ import {
getTeamsActivityTeamId,
getTeamsActivityTenantId,
isTeamsBotAuthoredActivity,
+ isTeamsNativeReactionType,
isTeamsTaskEntryActivity,
parseTeamsActivity,
teamsActivityToQueuedCommunicationMessage,
@@ -65,6 +66,7 @@ import {
} from '@roomote/cloud-agents/server';
import { apiLogger } from '../../logging.js';
+import { getCallRoomoteViaEmojiConfiguration } from '../call-roomote-via-emoji.js';
import { syncActingUserForInboundMessage } from '../tasks/acting-user-sync.js';
import {
attachOutOfBandContextToCommunicationMessage,
@@ -1648,7 +1650,7 @@ teams.post('/', async (c) => {
);
}
- const activity = parsed.data;
+ let activity = parsed.data;
const verificationError = await verifyTeamsWebhookAuthorization({
authorizationHeader: c.req.header('authorization'),
activity,
@@ -1677,6 +1679,48 @@ teams.post('/', async (c) => {
return c.json({ ok: true, ignored: 'bot_activity' });
}
+ if (activity.type === 'messageReaction') {
+ let configuration: Awaited<
+ ReturnType
+ > = null;
+ for (const reaction of activity.reactionsAdded ?? []) {
+ if (!isTeamsNativeReactionType(reaction.type)) {
+ continue;
+ }
+ configuration = await getCallRoomoteViaEmojiConfiguration(reaction.type);
+ if (configuration) {
+ break;
+ }
+ }
+
+ if (!configuration) {
+ return c.json({ ok: true, ignored: 'reaction_not_configured' });
+ }
+
+ const targetMessageId = activity.replyToId?.trim();
+ if (!targetMessageId) {
+ return c.json({ ok: true, ignored: 'reaction_target_missing' });
+ }
+
+ const mentionName = activity.recipient?.name?.trim() || PRODUCT_NAME;
+ const mentionText = `${mentionName} `;
+ activity = {
+ ...activity,
+ type: 'message',
+ id: activity.id ?? randomUUID(),
+ text: `${mentionText} ${configuration.prompt}`,
+ replyToId: targetMessageId,
+ entities: [
+ {
+ type: 'mention',
+ text: mentionText,
+ mentioned: activity.recipient,
+ },
+ ],
+ reactionsAdded: undefined,
+ };
+ }
+
await persistTeamsInstallationFromActivity(activity);
const mappedUserId = await findMappedTeamsUserId(activity);
diff --git a/apps/discord-gateway/src/dispatch.test.ts b/apps/discord-gateway/src/dispatch.test.ts
index 8d8060f04..16c71fbb8 100644
--- a/apps/discord-gateway/src/dispatch.test.ts
+++ b/apps/discord-gateway/src/dispatch.test.ts
@@ -50,6 +50,90 @@ describe('handleGatewayDispatch', () => {
});
});
+ it('enqueues message reactions with a deterministic event id', async () => {
+ const enqueue = vi.fn().mockResolvedValue(true);
+ const rest = { post: vi.fn() };
+ const payload = {
+ user_id: 'user-1',
+ channel_id: 'channel-1',
+ message_id: 'message-1',
+ guild_id: 'guild-1',
+ emoji: { id: null, name: 'white_check_mark' },
+ member: {
+ user: { id: 'user-1', username: 'matt' },
+ },
+ };
+
+ await expect(
+ handleGatewayDispatch(
+ { t: 'MESSAGE_REACTION_ADD', s: 42, d: payload },
+ {
+ enqueue,
+ rest,
+ now,
+ getSessionDedupeScope: () => 'session-a',
+ },
+ ),
+ ).resolves.toBe('enqueued');
+
+ expect(enqueue).toHaveBeenCalledWith({
+ eventId: 'channel-1:message-1:user-1:white_check_mark:session-a:42',
+ eventType: 'MESSAGE_REACTION_ADD',
+ payload,
+ receivedAt: '2026-07-12T12:00:00.000Z',
+ });
+ });
+
+ it('keeps separate reaction adds distinct while deduping Gateway replays', async () => {
+ const eventIds = new Set();
+ const enqueue = vi.fn(async (envelope: DiscordInboundEnvelope) => {
+ if (eventIds.has(envelope.eventId)) return false;
+ eventIds.add(envelope.eventId);
+ return true;
+ });
+ const rest = { post: vi.fn() };
+ const dependencies = {
+ enqueue,
+ rest,
+ now,
+ getSessionDedupeScope: () => 'session-a',
+ };
+ const payload = {
+ user_id: 'user-1',
+ channel_id: 'channel-1',
+ message_id: 'message-1',
+ emoji: { id: null, name: 'white_check_mark' },
+ };
+
+ await expect(
+ handleGatewayDispatch(
+ { t: 'MESSAGE_REACTION_ADD', s: 42, d: payload },
+ dependencies,
+ ),
+ ).resolves.toBe('enqueued');
+ await expect(
+ handleGatewayDispatch(
+ { t: 'MESSAGE_REACTION_ADD', s: 42, d: payload },
+ dependencies,
+ ),
+ ).resolves.toBe('duplicate');
+ await expect(
+ handleGatewayDispatch(
+ { t: 'MESSAGE_REACTION_ADD', s: 43, d: payload },
+ dependencies,
+ ),
+ ).resolves.toBe('enqueued');
+ await expect(
+ handleGatewayDispatch(
+ { t: 'MESSAGE_REACTION_ADD', s: 42, d: payload },
+ {
+ ...dependencies,
+ getSessionDedupeScope: () => 'session-b',
+ },
+ ),
+ ).resolves.toBe('enqueued');
+ });
+
it('normalizes a managed-role mention into a canonical bot mention', async () => {
const enqueue = vi.fn().mockResolvedValue(true);
const rest = { post: vi.fn() };
diff --git a/apps/discord-gateway/src/dispatch.ts b/apps/discord-gateway/src/dispatch.ts
index 2bebb6f58..c7a8f8590 100644
--- a/apps/discord-gateway/src/dispatch.ts
+++ b/apps/discord-gateway/src/dispatch.ts
@@ -8,6 +8,7 @@ import type {
type RawDispatch = {
t?: string | null;
+ s?: number | null;
d?: unknown;
};
@@ -29,6 +30,13 @@ type RawInteraction = {
data?: { name?: string };
};
+type RawReaction = {
+ user_id?: string;
+ channel_id?: string;
+ message_id?: string;
+ emoji?: { id?: string | null; name?: string | null };
+};
+
type DispatchDependencies = {
rest: Pick;
enqueue: (envelope: DiscordInboundEnvelope) => Promise;
@@ -39,6 +47,8 @@ type DispatchDependencies = {
/** The bot's managed role id for a guild, when known. */
getBotRoleId?: (guildId: string) => string | null | undefined;
getBotUsername?: () => string | undefined;
+ /** Stable across resumes, distinct after Discord creates a new session. */
+ getSessionDedupeScope?: () => string | undefined;
/**
* Forward an unmentioned guild message when Gateway channel metadata could
* not be resolved. The durable API consumer performs its own authoritative
@@ -125,6 +135,9 @@ function eventTypeFor(packet: RawDispatch): DiscordInboundEventType | null {
if (packet.t === 'INTERACTION_CREATE') {
return 'INTERACTION_CREATE';
}
+ if (packet.t === 'MESSAGE_REACTION_ADD') {
+ return 'MESSAGE_REACTION_ADD';
+ }
return null;
}
@@ -270,8 +283,30 @@ export async function handleGatewayDispatch(
};
}
- const payload = packet.d as RawMessage & RawInteraction;
- if (!payload.id) {
+ const payload = packet.d as RawMessage & RawInteraction & RawReaction;
+ const dispatchSequence =
+ typeof packet.s === 'number' && Number.isSafeInteger(packet.s)
+ ? packet.s
+ : null;
+ const sessionDedupeScope = dependencies.getSessionDedupeScope?.();
+ const reactionEventId =
+ eventType === 'MESSAGE_REACTION_ADD' &&
+ dispatchSequence !== null &&
+ payload.channel_id &&
+ payload.message_id &&
+ payload.user_id &&
+ payload.emoji?.name
+ ? [
+ payload.channel_id,
+ payload.message_id,
+ payload.user_id,
+ payload.emoji.id ?? payload.emoji.name,
+ ...(sessionDedupeScope ? [sessionDedupeScope] : []),
+ dispatchSequence,
+ ].join(':')
+ : null;
+ const eventId = payload.id ?? reactionEventId;
+ if (!eventId) {
return 'ignored';
}
@@ -281,7 +316,7 @@ export async function handleGatewayDispatch(
: undefined;
const enqueued = await dependencies.enqueue({
- eventId: payload.id,
+ eventId,
eventType,
payload: packet.d,
receivedAt: (dependencies.now?.() ?? new Date()).toISOString(),
diff --git a/apps/discord-gateway/src/gateway-resume-store.test.ts b/apps/discord-gateway/src/gateway-resume-store.test.ts
index eaf02f8af..6de7dd8e1 100644
--- a/apps/discord-gateway/src/gateway-resume-store.test.ts
+++ b/apps/discord-gateway/src/gateway-resume-store.test.ts
@@ -52,6 +52,35 @@ describe('DiscordGatewayResumeStore', () => {
});
});
+ it('keeps the session dedupe scope stable across process restarts', async () => {
+ const firstStore = new DiscordGatewayResumeStore(
+ 'fingerprint',
+ createRepository(),
+ 60_000,
+ );
+ const secondStore = new DiscordGatewayResumeStore(
+ 'fingerprint',
+ createRepository(),
+ 60_000,
+ );
+
+ await firstStore.retrieve(0);
+ await secondStore.retrieve(0);
+
+ expect(firstStore.getSessionDedupeScope(0)).toBe(
+ secondStore.getSessionDedupeScope(0),
+ );
+ expect(firstStore.getSessionDedupeScope(0)).not.toContain('session-1');
+
+ await secondStore.update(0, {
+ ...persistedSession,
+ sessionId: 'session-2',
+ });
+ expect(secondStore.getSessionDedupeScope(0)).not.toBe(
+ firstStore.getSessionDedupeScope(0),
+ );
+ });
+
it('persists a new session immediately and checkpoints only acknowledged dispatches', async () => {
const repository = createRepository();
repository.find.mockResolvedValueOnce(null);
diff --git a/apps/discord-gateway/src/gateway-resume-store.ts b/apps/discord-gateway/src/gateway-resume-store.ts
index 440584c36..d38004123 100644
--- a/apps/discord-gateway/src/gateway-resume-store.ts
+++ b/apps/discord-gateway/src/gateway-resume-store.ts
@@ -1,3 +1,5 @@
+import { createHash } from 'node:crypto';
+
import type { SessionInfo } from '@discordjs/ws';
import {
clearDiscordGatewayResumeState,
@@ -78,6 +80,16 @@ export class DiscordGatewayResumeStore {
return this.committedSession(shardId);
}
+ getSessionDedupeScope(shardId: number): string | undefined {
+ const sessionId = this.sessions.get(shardId)?.sessionId;
+ if (!sessionId) return undefined;
+
+ return createHash('sha256')
+ .update(`${this.tokenFingerprint}:${sessionId}`)
+ .digest('hex')
+ .slice(0, 16);
+ }
+
async update(shardId: number, session: SessionInfo | null): Promise {
const previous = this.sessions.get(shardId) ?? null;
this.loadedShards.add(shardId);
diff --git a/apps/discord-gateway/src/gateway-session.test.ts b/apps/discord-gateway/src/gateway-session.test.ts
index 90ca3aa9b..1013dcc1d 100644
--- a/apps/discord-gateway/src/gateway-session.test.ts
+++ b/apps/discord-gateway/src/gateway-session.test.ts
@@ -13,11 +13,13 @@ import {
} from './gateway-session';
describe('Discord Gateway intents', () => {
- it('subscribes to guilds, guild messages, DMs, and message content', () => {
+ it('subscribes to messages and reactions in guilds and DMs', () => {
expect(DISCORD_GATEWAY_INTENTS).toEqual([
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
+ GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.DirectMessages,
+ GatewayIntentBits.DirectMessageReactions,
GatewayIntentBits.MessageContent,
]);
});
@@ -112,7 +114,9 @@ describe('DiscordGatewaySession durable resume wiring', () => {
intents:
GatewayIntentBits.Guilds |
GatewayIntentBits.GuildMessages |
+ GatewayIntentBits.GuildMessageReactions |
GatewayIntentBits.DirectMessages |
+ GatewayIntentBits.DirectMessageReactions |
GatewayIntentBits.MessageContent,
});
expect(repository.find).toHaveBeenCalledWith({
@@ -175,6 +179,56 @@ describe('DiscordGatewaySession durable resume wiring', () => {
sequence: 43,
});
+ const reactionPayload = {
+ user_id: 'user-1',
+ channel_id: 'dm-1',
+ message_id: 'message-1',
+ emoji: { id: null, name: 'white_check_mark' },
+ };
+ await activeManagerOptions.updateSessionInfo(0, {
+ ...persisted,
+ sequence: 44,
+ });
+ const firstSessionScope = resumeStore.getSessionDedupeScope(0);
+ await listeners.get(WebSocketShardEvents.Dispatch)?.({
+ shardId: 0,
+ data: {
+ op: 0,
+ s: 44,
+ t: 'MESSAGE_REACTION_ADD',
+ d: reactionPayload,
+ },
+ });
+ expect(queue.enqueue).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ eventId: `dm-1:message-1:user-1:white_check_mark:${firstSessionScope}:44`,
+ }),
+ );
+
+ await activeManagerOptions.updateSessionInfo(0, {
+ ...persisted,
+ sessionId: 'session-2',
+ sequence: 44,
+ });
+ const secondSessionScope = resumeStore.getSessionDedupeScope(0);
+ expect(secondSessionScope).not.toBe(firstSessionScope);
+ await listeners.get(WebSocketShardEvents.Dispatch)?.({
+ shardId: 0,
+ data: {
+ op: 0,
+ s: 44,
+ t: 'MESSAGE_REACTION_ADD',
+ d: reactionPayload,
+ },
+ });
+ expect(queue.enqueue).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({
+ eventId: `dm-1:message-1:user-1:white_check_mark:${secondSessionScope}:44`,
+ }),
+ );
+
await session.disconnect();
expect(destroy).toHaveBeenCalledOnce();
@@ -211,6 +265,7 @@ describe('DiscordGatewaySession durable resume wiring', () => {
const resumeStore = {
retrieve: vi.fn(async () => null),
update: vi.fn(async () => undefined),
+ getSessionDedupeScope: vi.fn(() => 'session-scope'),
acknowledgeDispatch: vi.fn(() => true),
recordHeartbeat: vi.fn(async () => undefined),
flush: vi.fn(async () => undefined),
diff --git a/apps/discord-gateway/src/gateway-session.ts b/apps/discord-gateway/src/gateway-session.ts
index 561be968f..f5ce1a36f 100644
--- a/apps/discord-gateway/src/gateway-session.ts
+++ b/apps/discord-gateway/src/gateway-session.ts
@@ -16,7 +16,9 @@ import type { GatewayStatusStore } from './status';
export const DISCORD_GATEWAY_INTENTS = [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
+ GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.DirectMessages,
+ GatewayIntentBits.DirectMessageReactions,
GatewayIntentBits.MessageContent,
];
@@ -182,6 +184,8 @@ export class DiscordGatewaySession {
await handleGatewayDispatch(data, {
getBotRoleId: (guildId) => this.botRoleIds.get(guildId),
getBotUsername: () => this.botUsername,
+ getSessionDedupeScope: () =>
+ resumeStore.getSessionDedupeScope(shardId),
rest,
getBotUserId: () => this.botUserId,
getCachedChannel: (channelId) => this.channelCache.get(channelId),
diff --git a/apps/discord-gateway/src/inbound-queue.ts b/apps/discord-gateway/src/inbound-queue.ts
index af7713c98..9ab0175c9 100644
--- a/apps/discord-gateway/src/inbound-queue.ts
+++ b/apps/discord-gateway/src/inbound-queue.ts
@@ -43,7 +43,10 @@ redis.call('HDEL', KEYS[3], ARGV[1])
return deadLetterId
`;
-export type DiscordInboundEventType = 'MESSAGE_CREATE' | 'INTERACTION_CREATE';
+export type DiscordInboundEventType =
+ | 'MESSAGE_CREATE'
+ | 'INTERACTION_CREATE'
+ | 'MESSAGE_REACTION_ADD';
export type DiscordInboundEnvelope = {
eventId: string;
diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx
index 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/web/src/components/settings/automations/AutomationsSettings.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx
index f59dfd7ab..cf12ef546 100644
--- a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx
+++ b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx
@@ -32,6 +32,9 @@ import {
} from './ChannelAutoStartEditor';
const baseFormState: FormState = {
+ callRoomoteViaEmojiEnabled: false,
+ callRoomoteViaEmojiName: '',
+ callRoomoteViaEmojiInstructions: '',
reviewerEnabled: false,
reviewerEnvironmentScope: 'all' as const,
reviewerEnvironmentIds: [] as string[],
@@ -91,6 +94,26 @@ const baseFormState: FormState = {
};
describe('Automations selection helpers', () => {
+ it('includes emoji trigger settings in its save input', () => {
+ const saveInput = buildAutomationSettingsSaveInput(
+ {
+ ...baseFormState,
+ callRoomoteViaEmojiEnabled: true,
+ callRoomoteViaEmojiName: ' :white_check_mark: ',
+ callRoomoteViaEmojiInstructions: ' Prioritize safety. ',
+ },
+ baseFormState,
+ 'callRoomoteViaEmoji',
+ );
+
+ expect(saveInput).toMatchObject({
+ savingAutomation: 'callRoomoteViaEmoji',
+ callRoomoteViaEmojiEnabled: true,
+ callRoomoteViaEmojiName: ':white_check_mark:',
+ callRoomoteViaEmojiInstructions: 'Prioritize safety.',
+ });
+ });
+
it('keeps author scope specific when the last author is removed', () => {
const next = applyReviewerAuthorSelection(
{
diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx
index bae2ffe21..b1bfb95e1 100644
--- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx
+++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx
@@ -71,6 +71,9 @@ const state = vi.hoisted(() => ({
conflictResolverLabel: 'roomote:auto-resolve-conflicts',
conflictResolverInstructions: null,
reviewCodeInstructions: null as string | null,
+ callRoomoteViaEmojiEnabled: false,
+ callRoomoteViaEmojiName: null as string | null,
+ callRoomoteViaEmojiInstructions: null as string | null,
channelAutoStartSlackChannels: [
{
channelId: 'C123BUGS',
@@ -566,6 +569,27 @@ describe('AutomationsSettings', () => {
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
});
+ it('configures Call Roomote via emoji with a name and instructions', async () => {
+ render( );
+
+ fireEvent.click(
+ await screen.findByRole('button', {
+ name: 'Set up Call Roomote via emoji',
+ }),
+ );
+ fireEvent.click(
+ screen.getByRole('switch', {
+ name: 'Allow emoji reactions to call Roomote',
+ }),
+ );
+
+ expect(screen.getByLabelText('Emoji name')).toHaveAttribute(
+ 'placeholder',
+ ':white_check_mark:',
+ );
+ expect(screen.getByLabelText('Additional instructions')).toBeVisible();
+ });
+
it('shows additional instructions for Review Code', async () => {
state.settingsQuery.data.reviewer.enabled = true;
state.settingsQuery.data.settings.reviewer.enabled = true;
@@ -818,6 +842,9 @@ describe('AutomationsSettings', () => {
fireEvent.click(await screen.findByRole('option', { name: 'Operations' }));
expect(screen.getByText('Triage Sentry Issues')).toBeInTheDocument();
expect(screen.queryByText('Review Code')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('Call Roomote via emoji'),
+ ).not.toBeInTheDocument();
});
it('shows independent structural skeletons for custom and built-in automations', () => {
diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx
index e054eff12..f029a0710 100644
--- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx
+++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx
@@ -102,6 +102,7 @@ import {
SelectSeparator,
SelectTrigger,
SelectValue,
+ Smile,
MessagesSquare,
Skeleton,
SquarePen,
@@ -124,6 +125,8 @@ type FieldErrors = Partial<
| 'conflictResolverLabel'
| 'conflictResolverMaxPrAgeDays'
| 'conflictResolverInstructions'
+ | 'callRoomoteViaEmojiName'
+ | 'callRoomoteViaEmojiInstructions'
| 'channelAutoStartSlackChannels'
| 'channelAutoStartDiscordChannels'
| 'channelAutoStartInstructions'
@@ -491,6 +494,15 @@ const SCHEDULE_ONLY_AUTOMATIONS_BY_ID = Object.fromEntries(
>;
const AUTOMATION_DEFINITIONS: Record = {
+ callRoomoteViaEmoji: {
+ id: 'callRoomoteViaEmoji',
+ label: 'Call Roomote via emoji',
+ description:
+ 'Start or continue work in a Slack, Discord, or Teams thread by reacting with an emoji.',
+ icon: Smile,
+ category: 'communication',
+ searchTerms: ['Slack', 'Discord', 'Teams'],
+ },
channelAutoStart: {
id: 'channelAutoStart',
label: 'Auto-respond to channels',
@@ -573,6 +585,8 @@ const HASH_ALIAS_TO_AUTOMATION_ID: Record = {
]),
),
'auto-respond-channels': 'channelAutoStart',
+ 'call-roomote-via-emoji': 'callRoomoteViaEmoji',
+ 'emoji-trigger': 'callRoomoteViaEmoji',
autorespondchannels: 'channelAutoStart',
'auto-start-tasks': 'channelAutoStart',
channelautostart: 'channelAutoStart',
@@ -709,6 +723,9 @@ function mapSettingsToFormState(
}>;
};
reviewCodeInstructions: string | null;
+ callRoomoteViaEmojiEnabled: boolean;
+ callRoomoteViaEmojiName: string | null;
+ callRoomoteViaEmojiInstructions: string | null;
conflictResolverFrequency: ConflictResolverFrequency;
conflictResolverMaxPrAgeDays: ConflictResolverMaxPrAgeDays;
conflictResolverLabel: string;
@@ -775,6 +792,10 @@ function mapSettingsToFormState(
},
): FormState {
return {
+ callRoomoteViaEmojiEnabled: settings.callRoomoteViaEmojiEnabled,
+ callRoomoteViaEmojiName: settings.callRoomoteViaEmojiName ?? '',
+ callRoomoteViaEmojiInstructions:
+ settings.callRoomoteViaEmojiInstructions ?? '',
reviewerEnabled: settings.reviewer.enabled,
reviewerEnvironmentScope: 'all',
reviewerEnvironmentIds: [],
@@ -1856,6 +1877,17 @@ export function AutomationsSettings() {
return next;
});
}
+
+ if (
+ result.fieldErrors.callRoomoteViaEmojiName ||
+ result.fieldErrors.callRoomoteViaEmojiInstructions
+ ) {
+ setOpenAutomationIds((prev) => {
+ const next = new Set(prev);
+ next.add('callRoomoteViaEmoji');
+ return next;
+ });
+ }
return;
}
@@ -1981,6 +2013,7 @@ export function AutomationsSettings() {
if (!formState || !savedState) {
return {
+ callRoomoteViaEmoji: false,
channelAutoStart: false,
managerChannel: false,
managerStats: false,
@@ -1997,6 +2030,11 @@ export function AutomationsSettings() {
}
return {
+ callRoomoteViaEmoji: isAutomationDirty(
+ formState,
+ savedState,
+ 'callRoomoteViaEmoji',
+ ),
channelAutoStart: isAutomationDirty(
formState,
savedState,
@@ -2195,6 +2233,8 @@ export function AutomationsSettings() {
CHANNEL_AUTO_START_LAUNCH_MODE_OPTIONS;
const showChannelAutoStartLaunchModePicker = false;
const reviewerIsEnabled = formState?.reviewerEnabled ?? false;
+ const callRoomoteViaEmojiIsEnabled =
+ formState?.callRoomoteViaEmojiEnabled ?? false;
const conflictResolverIsEnabled =
formState?.conflictResolverFrequency !== 'off';
const channelAutoStartIsEnabled = hasConfiguredChannelAutoStartRows(
@@ -2576,6 +2616,7 @@ export function AutomationsSettings() {
);
const iconEnabled = {
+ callRoomoteViaEmoji: callRoomoteViaEmojiIsEnabled,
channelAutoStart: channelAutoStartIsEnabled,
managerChannel: managerChannelIsEnabled,
managerStats: managerStatsIsEnabled,
@@ -2753,7 +2794,7 @@ export function AutomationsSettings() {
@@ -2801,6 +2842,115 @@ export function AutomationsSettings() {
No available automations match these filters.
) : null}
+
+ setAutomationOpen('callRoomoteViaEmoji', open)
+ }
+ iconEnabled={iconEnabled.callRoomoteViaEmoji}
+ footer={
+ saveAgent('callRoomoteViaEmoji')}
+ onReset={() => resetAgent('callRoomoteViaEmoji')}
+ />
+ }
+ >
+
+
+
+ setFormState((prev) =>
+ prev ? { ...prev, callRoomoteViaEmojiEnabled } : prev,
+ )
+ }
+ />
+
+ Allow emoji reactions to call Roomote
+
+
+
+ {callRoomoteViaEmojiIsEnabled ? (
+
+
+
+ Emoji name
+
+
+ setFormState((prev) =>
+ prev
+ ? {
+ ...prev,
+ callRoomoteViaEmojiName: event.target.value,
+ }
+ : prev,
+ )
+ }
+ placeholder=":white_check_mark:"
+ />
+
+ Enter the reaction name, with or without surrounding
+ colons. Microsoft Teams supports its native Like, Heart,
+ Laugh, Surprised, Sad, and Angry reactions on messages
+ posted by Roomote.
+
+ {fieldErrors.callRoomoteViaEmojiName ? (
+
+ {fieldErrors.callRoomoteViaEmojiName}
+
+ ) : null}
+
+
+
+
+ Additional instructions
+
+
+
+ ) : null}
+
+
+
;
export type FormState = {
+ callRoomoteViaEmojiEnabled: boolean;
+ callRoomoteViaEmojiName: string;
+ callRoomoteViaEmojiInstructions: string;
reviewerEnabled: boolean;
reviewerEnvironmentScope: ReviewerEnvironmentScope;
reviewerEnvironmentIds: string[];
@@ -108,6 +111,7 @@ export type FormState = {
ScheduleOnlyAutomationFormFields;
export type AutomationId =
+ | 'callRoomoteViaEmoji'
| 'channelAutoStart'
| 'managerChannel'
| 'managerStats'
@@ -136,6 +140,12 @@ const REVIEWER_FIELDS: Array = [
'reviewerRelayUserIds',
];
+const CALL_ROOMOTE_VIA_EMOJI_FIELDS: Array = [
+ 'callRoomoteViaEmojiEnabled',
+ 'callRoomoteViaEmojiName',
+ 'callRoomoteViaEmojiInstructions',
+];
+
const CONFLICT_RESOLVER_FIELDS: Array = [
'conflictResolverFrequency',
'conflictResolverMaxPrAgeDays',
@@ -206,6 +216,7 @@ const SCHEDULE_ONLY_AUTOMATION_FIELDS = Object.fromEntries(
) as Record>;
const AUTOMATION_FIELDS: Record> = {
+ callRoomoteViaEmoji: CALL_ROOMOTE_VIA_EMOJI_FIELDS,
channelAutoStart: CHANNEL_AUTO_START_FIELDS,
managerChannel: MANAGER_CHANNEL_FIELDS,
managerStats: MANAGER_STATS_FIELDS,
@@ -304,6 +315,10 @@ export function buildAutomationSettingsSaveInput(
return {
savingAutomation: automationId,
+ callRoomoteViaEmojiEnabled: stateToSave.callRoomoteViaEmojiEnabled,
+ callRoomoteViaEmojiName: stateToSave.callRoomoteViaEmojiName.trim() || null,
+ callRoomoteViaEmojiInstructions:
+ stateToSave.callRoomoteViaEmojiInstructions.trim() || null,
reviewerEnabled: stateToSave.reviewerEnabled,
reviewerEnvironmentScope: 'all' as const,
reviewerEnvironmentIds: [],
diff --git a/apps/web/src/components/system/primitives/icons.ts b/apps/web/src/components/system/primitives/icons.ts
index bac8767c1..1209e6c5d 100644
--- a/apps/web/src/components/system/primitives/icons.ts
+++ b/apps/web/src/components/system/primitives/icons.ts
@@ -169,6 +169,7 @@ export {
Shapes,
Slack,
Slash,
+ Smile,
Sparkles,
Square,
SquareArrowOutUpRight,
diff --git a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts
index 89969a303..ee0382dfe 100644
--- a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts
+++ b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts
@@ -202,6 +202,30 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => {
await db.delete(users);
});
+ it('preserves a disabled emoji trigger during an unrelated save', async () => {
+ await upsertAutomation(db, {
+ key: 'call_roomote_via_emoji',
+ enabled: false,
+ instructions: 'Prioritize safety.',
+ settings: { emoji: ':white_check_mark:' },
+ });
+
+ const result = await updateBackgroundAgentSettingsCommand(
+ adminAuth,
+ buildInput({ savingAutomation: 'managerStats' }),
+ );
+ const automation = await db.query.automations.findFirst({
+ where: eq(automations.key, 'call_roomote_via_emoji'),
+ });
+
+ expect(result.success).toBe(true);
+ expect(automation).toMatchObject({
+ enabled: false,
+ instructions: 'Prioritize safety.',
+ settings: { emoji: ':white_check_mark:' },
+ });
+ });
+
it('saves a Discord manager channel without Slack and returns the persisted id', async () => {
await insertAvailableDiscordChannel({
guildId: 'guild-1',
diff --git a/apps/web/src/trpc/commands/automations/settings-update.ts b/apps/web/src/trpc/commands/automations/settings-update.ts
index 552e57004..53f2ff558 100644
--- a/apps/web/src/trpc/commands/automations/settings-update.ts
+++ b/apps/web/src/trpc/commands/automations/settings-update.ts
@@ -238,6 +238,17 @@ export async function updateBackgroundAgentSettingsCommand(
assertAdmin(auth);
const fieldErrors: BackgroundAgentFieldErrors = {};
const existingSettings = await getBackgroundAgentSettingsForDeployment();
+ const shouldUpdateCallRoomoteViaEmoji =
+ input.savingAutomation === 'callRoomoteViaEmoji';
+ const callRoomoteViaEmojiEnabled = shouldUpdateCallRoomoteViaEmoji
+ ? input.callRoomoteViaEmojiEnabled === true
+ : existingSettings.callRoomoteViaEmojiEnabled;
+ const callRoomoteViaEmojiName = shouldUpdateCallRoomoteViaEmoji
+ ? normalizeOptionalText(input.callRoomoteViaEmojiName)
+ : existingSettings.callRoomoteViaEmojiName;
+ const callRoomoteViaEmojiInstructions = shouldUpdateCallRoomoteViaEmoji
+ ? normalizeOptionalText(input.callRoomoteViaEmojiInstructions)
+ : existingSettings.callRoomoteViaEmojiInstructions;
const shouldUpdateChannelAutoStart =
input.savingAutomation === 'channelAutoStart';
const destinationDescriptors = listAutomationDestinationDescriptors();
@@ -280,6 +291,21 @@ export async function updateBackgroundAgentSettingsCommand(
fieldErrors.reviewerInstructions = 'Review Code instructions are too long.';
}
+ if (
+ shouldUpdateCallRoomoteViaEmoji &&
+ callRoomoteViaEmojiEnabled &&
+ !callRoomoteViaEmojiName
+ ) {
+ fieldErrors.callRoomoteViaEmojiName = 'Choose an emoji name.';
+ } else if ((callRoomoteViaEmojiName?.length ?? 0) > 100) {
+ fieldErrors.callRoomoteViaEmojiName = 'Emoji name is too long.';
+ }
+
+ if ((callRoomoteViaEmojiInstructions?.length ?? 0) > 8_000) {
+ fieldErrors.callRoomoteViaEmojiInstructions =
+ 'Additional instructions are too long.';
+ }
+
const channelAutoStartRows = shouldUpdateChannelAutoStart
? normalizeChannelAutoStartInputRows({
rows: input.channelAutoStartSlackChannels,
@@ -1042,6 +1068,16 @@ export async function updateBackgroundAgentSettingsCommand(
},
});
+ await upsertAutomation(tx, {
+ key: 'call_roomote_via_emoji',
+ enabled: callRoomoteViaEmojiEnabled && Boolean(callRoomoteViaEmojiName),
+ instructions: callRoomoteViaEmojiInstructions,
+ settings: {
+ ...(callRoomoteViaEmojiName ? { emoji: callRoomoteViaEmojiName } : {}),
+ },
+ updatedAt: now,
+ });
+
await upsertAutomation(tx, {
key: 'review_code',
enabled: input.reviewerEnabled,
diff --git a/apps/web/src/trpc/commands/automations/types.ts b/apps/web/src/trpc/commands/automations/types.ts
index 0a72b768e..e0442540e 100644
--- a/apps/web/src/trpc/commands/automations/types.ts
+++ b/apps/web/src/trpc/commands/automations/types.ts
@@ -24,6 +24,8 @@ export type BackgroundAgentFieldErrorKey =
| 'conflictResolverLabel'
| 'conflictResolverMaxPrAgeDays'
| 'conflictResolverInstructions'
+ | 'callRoomoteViaEmojiName'
+ | 'callRoomoteViaEmojiInstructions'
| 'channelAutoStartSlackChannels'
| 'channelAutoStartDiscordChannels'
| 'channelAutoStartInstructions'
@@ -220,6 +222,7 @@ export interface ResolvedChannelAutoStartDiscordRow {
export interface UpdateBackgroundAgentSettingsInput extends ScheduleOnlyAutomationInputFields {
savingAutomation:
+ | 'callRoomoteViaEmoji'
| 'channelAutoStart'
| 'managerChannel'
| 'managerStats'
@@ -248,6 +251,9 @@ export interface UpdateBackgroundAgentSettingsInput extends ScheduleOnlyAutomati
conflictResolverMaxPrAgeDays?: ConflictResolverMaxPrAgeDays;
conflictResolverLabel: string;
conflictResolverInstructions: string | null;
+ callRoomoteViaEmojiEnabled?: boolean;
+ callRoomoteViaEmojiName?: string | null;
+ callRoomoteViaEmojiInstructions?: string | null;
issueFixerInstructions?: string | null;
channelAutoStartSlackChannels?: ChannelAutoStartInputRow[];
/**
diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts
index f3c18f437..c996e37b8 100644
--- a/apps/web/src/trpc/routers/_app.ts
+++ b/apps/web/src/trpc/routers/_app.ts
@@ -398,6 +398,7 @@ const SCHEDULE_ONLY_BACKGROUND_AUTOMATION_FREQUENCY_SCHEMA = z.enum(
);
const UPDATE_SETTINGS_SAVING_AUTOMATION_VALUES = [
+ 'callRoomoteViaEmoji',
'channelAutoStart',
'managerChannel',
'managerStats',
@@ -482,6 +483,19 @@ const automationsRouter = createRouter({
conflictResolverMaxPrAgeDaysSchema.optional(),
conflictResolverLabel: z.string().trim().min(1).max(255),
conflictResolverInstructions: z.string().max(8_000).nullable(),
+ callRoomoteViaEmojiEnabled: z.boolean().optional(),
+ callRoomoteViaEmojiName: z
+ .string()
+ .trim()
+ .min(1)
+ .max(100)
+ .nullable()
+ .optional(),
+ callRoomoteViaEmojiInstructions: z
+ .string()
+ .max(8_000)
+ .nullable()
+ .optional(),
channelAutoStartSlackChannels: z
.array(
z.object({
diff --git a/packages/communication/package.json b/packages/communication/package.json
index 5825e6a7b..f61e762f8 100644
--- a/packages/communication/package.json
+++ b/packages/communication/package.json
@@ -12,6 +12,7 @@
"./messages": "./src/messages.ts",
"./mock-discord-server": "./src/mock-discord-server.ts",
"./provider": "./src/provider.ts",
+ "./reaction-emoji": "./src/reaction-emoji.ts",
"./redact-secrets": "./src/redact-secrets.ts",
"./request-user-input": "./src/request-user-input.ts",
"./task-thread-title": "./src/task-thread-title.ts",
diff --git a/packages/communication/src/__tests__/reaction-emoji.test.ts b/packages/communication/src/__tests__/reaction-emoji.test.ts
new file mode 100644
index 000000000..54cc1ebdb
--- /dev/null
+++ b/packages/communication/src/__tests__/reaction-emoji.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ normalizeReactionEmoji,
+ reactionEmojiMatches,
+} from '../reaction-emoji';
+
+describe('reaction emoji matching', () => {
+ it('normalizes colon-wrapped aliases', () => {
+ expect(normalizeReactionEmoji(':white_check_mark:')).toBe('✅');
+ expect(reactionEmojiMatches(':white_check_mark:', '✅')).toBe(true);
+ });
+
+ it('strips long colon runs in linear time', () => {
+ const colons = ':'.repeat(100_000);
+ expect(normalizeReactionEmoji(`${colons}ship_it${colons}`)).toBe('ship_it');
+ });
+
+ it('matches provider aliases for the same reaction', () => {
+ expect(reactionEmojiMatches('thumbsup', 'like')).toBe(true);
+ expect(reactionEmojiMatches(':+1:', '👍')).toBe(true);
+ });
+
+ it('matches custom emoji names case-insensitively', () => {
+ expect(reactionEmojiMatches(':Ship_It:', 'ship_it')).toBe(true);
+ expect(reactionEmojiMatches(':ship_it:', 'eyes')).toBe(false);
+ });
+});
diff --git a/packages/communication/src/discord-event.ts b/packages/communication/src/discord-event.ts
index f76287736..19517c9fe 100644
--- a/packages/communication/src/discord-event.ts
+++ b/packages/communication/src/discord-event.ts
@@ -134,9 +134,40 @@ const discordInteractionCreateDispatchSchema = z
})
.passthrough();
+const discordReactionAddSchema = z
+ .object({
+ user_id: z.string(),
+ channel_id: z.string(),
+ message_id: z.string(),
+ guild_id: z.string().optional(),
+ emoji: z
+ .object({
+ id: z.string().nullable().optional(),
+ name: z.string().nullable(),
+ })
+ .passthrough(),
+ member: z
+ .object({
+ user: discordUserSchema.optional(),
+ })
+ .passthrough()
+ .optional(),
+ })
+ .passthrough();
+
+const discordReactionAddDispatchSchema = z
+ .object({
+ op: z.literal(0),
+ t: z.literal('MESSAGE_REACTION_ADD'),
+ s: z.number().int().nullable().optional(),
+ d: discordReactionAddSchema,
+ })
+ .passthrough();
+
export const discordGatewayDispatchSchema = z.discriminatedUnion('t', [
discordMessageCreateDispatchSchema,
discordInteractionCreateDispatchSchema,
+ discordReactionAddDispatchSchema,
]);
const discordMessageEnvelopeSchema = z
@@ -159,16 +190,27 @@ const discordInteractionEnvelopeSchema = z
})
.passthrough();
+const discordReactionAddEnvelopeSchema = z
+ .object({
+ eventId: z.string(),
+ eventType: z.literal('MESSAGE_REACTION_ADD'),
+ payload: discordReactionAddSchema,
+ receivedAt: z.string().datetime(),
+ })
+ .passthrough();
+
/** Durable envelope forwarded from the Discord Gateway service to the API. */
export const discordGatewayEventSchema = z.discriminatedUnion('eventType', [
discordMessageEnvelopeSchema,
discordInteractionEnvelopeSchema,
+ discordReactionAddEnvelopeSchema,
]);
export type DiscordUser = z.infer;
export type DiscordAttachment = z.infer;
export type DiscordMessage = z.infer;
export type DiscordInteraction = z.infer;
+export type DiscordReactionAdd = z.infer;
export type DiscordGatewayDispatch = z.infer<
typeof discordGatewayDispatchSchema
>;
@@ -230,6 +272,12 @@ export function getDiscordInteractionCreate(
return event.eventType === 'INTERACTION_CREATE' ? event.payload : undefined;
}
+export function getDiscordReactionAdd(
+ event: DiscordGatewayEvent,
+): DiscordReactionAdd | undefined {
+ return event.eventType === 'MESSAGE_REACTION_ADD' ? event.payload : undefined;
+}
+
export function getDiscordInteractionUser(
interaction: DiscordInteraction,
): DiscordUser | undefined {
@@ -241,6 +289,13 @@ function getEventChannel(event: DiscordGatewayEvent): {
parentChannelId?: string;
guildId?: string;
} {
+ if (event.eventType === 'MESSAGE_REACTION_ADD') {
+ return {
+ channelId: event.payload.channel_id,
+ ...(event.payload.guild_id ? { guildId: event.payload.guild_id } : {}),
+ };
+ }
+
const data = event.payload;
const channelId =
data.channel_id ?? ('channel' in data ? data.channel?.id : undefined);
@@ -267,7 +322,7 @@ export function getDiscordEventCommunicationMetadata(
communicationProvider: 'discord',
communicationChannelId: parentChannelId ?? channel.channelId,
...(parentChannelId ? { communicationThreadId: channel.channelId } : {}),
- communicationMessageId: event.payload.id,
+ communicationMessageId: event.eventId,
...(channel.guildId ? { communicationGuildId: channel.guildId } : {}),
...(message ? { communicationAnchorMessageId: message.id } : {}),
};
@@ -290,7 +345,8 @@ function isDiscordGatewayEventValue(
return (
'eventType' in value &&
(value.eventType === 'MESSAGE_CREATE' ||
- value.eventType === 'INTERACTION_CREATE') &&
+ value.eventType === 'INTERACTION_CREATE' ||
+ value.eventType === 'MESSAGE_REACTION_ADD') &&
'payload' in value
);
}
diff --git a/packages/communication/src/index.ts b/packages/communication/src/index.ts
index b7a7d6a11..41fddcbad 100644
--- a/packages/communication/src/index.ts
+++ b/packages/communication/src/index.ts
@@ -4,6 +4,7 @@ export * from './discord-provider';
export * from './discord-request-user-input';
export * from './messages';
export * from './provider';
+export * from './reaction-emoji';
export * from './request-user-input';
export * from './task-thread-title';
export * from './teams-activity';
diff --git a/packages/communication/src/reaction-emoji.ts b/packages/communication/src/reaction-emoji.ts
new file mode 100644
index 000000000..7f288226c
--- /dev/null
+++ b/packages/communication/src/reaction-emoji.ts
@@ -0,0 +1,56 @@
+const REACTION_EMOJI_BY_NAME: Record = {
+ eyes: '👀',
+ thumbsup: '👍',
+ '+1': '👍',
+ like: '👍',
+ thumbsdown: '👎',
+ '-1': '👎',
+ heart: '❤️',
+ white_check_mark: '✅',
+ heavy_check_mark: '✔️',
+ x: '❌',
+ tada: '🎉',
+ fire: '🔥',
+ clap: '👏',
+ laugh: '😆',
+ joy: '😆',
+ smile: '😄',
+ surprised: '😮',
+ open_mouth: '😮',
+ scream: '😱',
+ sad: '😢',
+ cry: '😢',
+ angry: '😠',
+ rage: '😡',
+ think: '🤔',
+ thinking_face: '🤔',
+ ok_hand: '👌',
+ pray: '🙏',
+ '100': '💯',
+ wave: '👋',
+ trophy: '🏆',
+ handshake: '🤝',
+ saluting_face: '🫡',
+ rocket: '🚀',
+};
+
+export function normalizeReactionEmoji(value: string): string {
+ const trimmed = value.trim();
+ let start = 0;
+ let end = trimmed.length;
+ while (start < end && trimmed.charCodeAt(start) === 58) start += 1;
+ while (end > start && trimmed.charCodeAt(end - 1) === 58) end -= 1;
+ const normalized = trimmed.slice(start, end).toLowerCase();
+ return REACTION_EMOJI_BY_NAME[normalized] ?? normalized;
+}
+
+export function reactionEmojiMatches(
+ configuredEmoji: string,
+ receivedEmoji: string,
+): boolean {
+ return (
+ Boolean(configuredEmoji.trim()) &&
+ normalizeReactionEmoji(configuredEmoji) ===
+ normalizeReactionEmoji(receivedEmoji)
+ );
+}
diff --git a/packages/communication/src/teams-activity.ts b/packages/communication/src/teams-activity.ts
index fa8672337..24e87c557 100644
--- a/packages/communication/src/teams-activity.ts
+++ b/packages/communication/src/teams-activity.ts
@@ -71,12 +71,34 @@ export const teamsActivitySchema = z
channelData: teamsActivityChannelDataSchema.optional(),
entities: z.array(teamsActivityMentionEntitySchema).optional(),
replyToId: z.string().optional(),
+ reactionsAdded: z
+ .array(
+ z
+ .object({
+ type: z.string(),
+ })
+ .passthrough(),
+ )
+ .optional(),
attachments: z.array(z.unknown()).optional(),
})
.passthrough();
export type TeamsActivity = z.infer;
+const TEAMS_NATIVE_REACTION_TYPES = new Set([
+ 'like',
+ 'heart',
+ 'laugh',
+ 'surprised',
+ 'sad',
+ 'angry',
+]);
+
+export function isTeamsNativeReactionType(value: string): boolean {
+ return TEAMS_NATIVE_REACTION_TYPES.has(value.trim().toLowerCase());
+}
+
export type TeamsActivityCommunicationMetadata = {
communicationProvider: 'teams';
communicationTeamId?: string;
diff --git a/packages/db/src/lib/automations.test.ts b/packages/db/src/lib/automations.test.ts
index 1612a1ba8..2633e8084 100644
--- a/packages/db/src/lib/automations.test.ts
+++ b/packages/db/src/lib/automations.test.ts
@@ -170,3 +170,35 @@ describe('normalizeBackgroundAgentSettings channel auto-start', () => {
expect(settings.channelAutoStartEnabled).toBe(false);
});
});
+
+describe('normalizeBackgroundAgentSettings emoji trigger', () => {
+ it('projects the configured emoji and instructions when enabled', () => {
+ const settings = normalizeBackgroundAgentSettings(null, [
+ {
+ key: 'call_roomote_via_emoji',
+ enabled: true,
+ instructions: 'Prioritize safety.',
+ settings: { emoji: ':white_check_mark:' },
+ targets: [],
+ } as unknown as Automation,
+ ]);
+
+ expect(settings.callRoomoteViaEmojiName).toBe(':white_check_mark:');
+ expect(settings.callRoomoteViaEmojiEnabled).toBe(true);
+ expect(settings.callRoomoteViaEmojiInstructions).toBe('Prioritize safety.');
+ });
+
+ it('preserves the stored emoji when disabled', () => {
+ const settings = normalizeBackgroundAgentSettings(null, [
+ {
+ key: 'call_roomote_via_emoji',
+ enabled: false,
+ settings: { emoji: 'eyes' },
+ targets: [],
+ } as unknown as Automation,
+ ]);
+
+ expect(settings.callRoomoteViaEmojiEnabled).toBe(false);
+ expect(settings.callRoomoteViaEmojiName).toBe('eyes');
+ });
+});
diff --git a/packages/db/src/lib/automations.ts b/packages/db/src/lib/automations.ts
index 8676300a7..b31797cfe 100644
--- a/packages/db/src/lib/automations.ts
+++ b/packages/db/src/lib/automations.ts
@@ -902,6 +902,7 @@ export function normalizeBackgroundAgentSettings(
const conflictResolver = automationMap.get('conflict_resolver');
const suggester = automationMap.get('suggester');
const announcer = automationMap.get('announcer');
+ const callRoomoteViaEmoji = automationMap.get('call_roomote_via_emoji');
const channelAutoStart = automationMap.get('slack_channel_auto_start');
const managerStats = automationMap.get('manager_stats');
const sentryTriage = automationMap.get('sentry_triage');
@@ -969,6 +970,15 @@ export function normalizeBackgroundAgentSettings(
announcerInstructions: announcer?.instructions ?? null,
announcerLastRunAt: announcer?.lastRunAt ?? null,
+ callRoomoteViaEmojiEnabled:
+ callRoomoteViaEmoji?.enabled === true &&
+ Boolean(getAutomationSettingText(callRoomoteViaEmoji, 'emoji')),
+ callRoomoteViaEmojiName: getAutomationSettingText(
+ callRoomoteViaEmoji,
+ 'emoji',
+ ),
+ callRoomoteViaEmojiInstructions: callRoomoteViaEmoji?.instructions ?? null,
+
channelAutoStartSlackChannels: channelAutoStartTargets,
channelAutoStartDiscordChannels: channelAutoStartDiscordTargets,
channelAutoStartEnabled:
diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts
index 84db91281..5d24f85ce 100644
--- a/packages/db/src/types.ts
+++ b/packages/db/src/types.ts
@@ -451,6 +451,9 @@ export type ChannelAutoStartChannelSettings = {
};
export type BackgroundAgentSettings = StoredBackgroundAgentSettings & {
+ callRoomoteViaEmojiEnabled: boolean;
+ callRoomoteViaEmojiName: string | null;
+ callRoomoteViaEmojiInstructions: string | null;
channelAutoStartSlackChannels: ChannelAutoStartChannelSettings[];
channelAutoStartDiscordChannels: ChannelAutoStartChannelSettings[];
channelAutoStartEnabled: boolean;
diff --git a/packages/types/src/background-agents.ts b/packages/types/src/background-agents.ts
index 2a2ad03d5..f4cdc97a1 100644
--- a/packages/types/src/background-agents.ts
+++ b/packages/types/src/background-agents.ts
@@ -81,6 +81,7 @@ export const USER_FACING_AUTOMATION_KEYS = [
'conflict_resolver',
'suggester',
'announcer',
+ 'call_roomote_via_emoji',
// Channel auto-start for ALL chat providers (Slack + Discord targets live in
// this one row, distinguished by target provider/targetKind). The key keeps
// its historical Slack-only name because renaming an automations primary key
From 04d3fc001e43827729dc37d9493cea3a06bdc825 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 11:21:36 +0100
Subject: [PATCH 03/13] refactor: extract manager channel automation editor
(#1075)
Co-authored-by: Roomote
---
.../AutomationsSettings.client.test.tsx | 211 ------
.../automations/AutomationsSettings.tsx | 612 ++----------------
.../ManagerChannelEditor.client.test.tsx | 64 ++
.../automations/ManagerChannelEditor.tsx | 404 ++++++++++++
.../automations/channelOptions.client.test.ts | 119 ++++
.../settings/automations/channelOptions.ts | 142 ++++
6 files changed, 793 insertions(+), 759 deletions(-)
create mode 100644 apps/web/src/components/settings/automations/ManagerChannelEditor.client.test.tsx
create mode 100644 apps/web/src/components/settings/automations/ManagerChannelEditor.tsx
create mode 100644 apps/web/src/components/settings/automations/channelOptions.client.test.ts
create mode 100644 apps/web/src/components/settings/automations/channelOptions.ts
diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx
index cf12ef546..593fddafe 100644
--- a/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx
+++ b/apps/web/src/components/settings/automations/AutomationsSettings.client.test.tsx
@@ -11,20 +11,13 @@ import {
} from './formState';
import { applyReviewerAuthorSelection } from './reviewerSelection';
import {
- buildAutomationDiscordDestinationOptions,
- buildCustomManagerSlackChannelOption,
buildSlackWorkflowLaunchUrl,
- buildManagerSlackChannelOptions,
- DISCORD_DESTINATION_OPTION_PREFIX,
canSaveSentryTriageSettings,
canSelectSentryTriageFrequency,
- formatSlackChannelValue,
getAutomationRunTooltip,
isAutomationRunDisabled,
- isManagerChannelSelectionDisabled,
isPlatformIssueAlertsEnabled,
resolveAutomationHashTarget,
- shouldShowManagerSlackChannelWarning,
} from './AutomationsSettings';
import {
createChannelAutoStartRowFromTemplate,
@@ -675,210 +668,6 @@ describe('Automations selection helpers', () => {
);
});
- it('shows manager channel access warnings when the form displays the channel name', () => {
- expect(
- shouldShowManagerSlackChannelWarning({
- formValue: '#roomote-managers',
- savedChannelId: 'C123MANAGER',
- warningChannelId: 'C123MANAGER',
- isDirty: false,
- }),
- ).toBe(true);
- });
-
- it('does not show stale manager channel access warnings while the channel edit is dirty', () => {
- expect(
- shouldShowManagerSlackChannelWarning({
- formValue: '#other-channel',
- savedChannelId: 'C123MANAGER',
- warningChannelId: 'C123MANAGER',
- isDirty: true,
- }),
- ).toBe(false);
- });
-
- it('formats Slack channel values for display', () => {
- expect(formatSlackChannelValue('roomote-managers')).toBe(
- '#roomote-managers',
- );
- expect(formatSlackChannelValue('#roomote-managers')).toBe(
- '#roomote-managers',
- );
- expect(formatSlackChannelValue('C123MANAGER')).toBe('C123MANAGER');
- });
-
- it('preserves a selected manager channel when it is not in the fetched list', () => {
- expect(
- buildManagerSlackChannelOptions({
- channels: [{ id: 'C456', name: 'engineering' }],
- selectedValue: '#roomote-managers',
- }),
- ).toEqual([
- {
- id: '#roomote-managers',
- name: 'roomote-managers',
- label: '#roomote-managers',
- },
- {
- id: 'C456',
- name: 'engineering',
- label: '#engineering',
- },
- ]);
- });
-
- it('does not duplicate a fetched manager channel option when the selected value already matches it', () => {
- expect(
- buildManagerSlackChannelOptions({
- channels: [{ id: 'C123MANAGER', name: 'roomote-managers' }],
- selectedValue: '#roomote-managers',
- }),
- ).toEqual([
- {
- id: 'C123MANAGER',
- name: 'roomote-managers',
- label: '#roomote-managers',
- },
- ]);
- });
-
- it('prefixes Discord destination options so they never collide with Slack channel ids', () => {
- expect(
- buildAutomationDiscordDestinationOptions({
- channels: [{ id: '111', name: 'general', label: '#general' }],
- selectedChannelId: null,
- includeProviderSuffix: true,
- }),
- ).toEqual([
- {
- id: `${DISCORD_DESTINATION_OPTION_PREFIX}111`,
- name: 'general',
- label: '#general (Discord)',
- },
- ]);
- });
-
- it('keeps a saved Discord channel selectable when the catalog no longer lists it', () => {
- expect(
- buildAutomationDiscordDestinationOptions({
- channels: [{ id: '111', name: 'general', label: '#general' }],
- selectedChannelId: '222',
- includeProviderSuffix: true,
- }),
- ).toEqual([
- {
- id: `${DISCORD_DESTINATION_OPTION_PREFIX}222`,
- name: '222',
- label: '#222 (Discord)',
- },
- {
- id: `${DISCORD_DESTINATION_OPTION_PREFIX}111`,
- name: 'general',
- label: '#general (Discord)',
- },
- ]);
- });
-
- it('drops the provider suffix when Discord is the only connected provider', () => {
- expect(
- buildAutomationDiscordDestinationOptions({
- channels: [{ id: '111', name: 'general', label: '#general' }],
- selectedChannelId: '222',
- includeProviderSuffix: false,
- }),
- ).toEqual([
- {
- id: `${DISCORD_DESTINATION_OPTION_PREFIX}222`,
- name: '222',
- label: '#222',
- },
- {
- id: `${DISCORD_DESTINATION_OPTION_PREFIX}111`,
- name: 'general',
- label: '#general',
- },
- ]);
- });
-
- it('does not duplicate a Discord option for a selection the catalog already lists', () => {
- expect(
- buildAutomationDiscordDestinationOptions({
- channels: [{ id: '111', name: 'general', label: '#general' }],
- selectedChannelId: '111',
- includeProviderSuffix: true,
- }),
- ).toEqual([
- {
- id: `${DISCORD_DESTINATION_OPTION_PREFIX}111`,
- name: 'general',
- label: '#general (Discord)',
- },
- ]);
- });
-
- it('offers a manual manager channel option for private channel names', () => {
- expect(
- buildCustomManagerSlackChannelOption({
- searchValue: 'roomote-managers-private',
- options: [{ id: 'C456', name: 'engineering', label: '#engineering' }],
- }),
- ).toEqual({
- id: 'roomote-managers-private',
- name: 'roomote-managers-private',
- label: '#roomote-managers-private',
- });
- });
-
- it('offers a manual manager channel option for raw Slack channel ids', () => {
- expect(
- buildCustomManagerSlackChannelOption({
- searchValue: 'C123MANAGER',
- options: [{ id: 'C456', name: 'engineering', label: '#engineering' }],
- }),
- ).toEqual({
- id: 'C123MANAGER',
- name: 'C123MANAGER',
- label: 'C123MANAGER',
- });
- });
-
- it('does not offer a manual manager channel option when search matches a fetched channel', () => {
- expect(
- buildCustomManagerSlackChannelOption({
- searchValue: 'roomote-managers',
- options: [
- {
- id: 'C123MANAGER',
- name: 'roomote-managers',
- label: '#roomote-managers',
- },
- ],
- }),
- ).toBeNull();
- });
-
- it('keeps manager channel selection enabled when Slack is disconnected but a value is already configured', () => {
- expect(
- isManagerChannelSelectionDisabled({
- slackConnected: false,
- isFetching: false,
- hasValue: true,
- isConfigured: true,
- }),
- ).toBe(false);
- });
-
- it('disables manager channel selection when Slack is disconnected and nothing is configured', () => {
- expect(
- isManagerChannelSelectionDisabled({
- slackConnected: false,
- isFetching: false,
- hasValue: false,
- isConfigured: false,
- }),
- ).toBe(true);
- });
-
it('treats platform issue alerts as disabled without a selected channel', () => {
expect(
isPlatformIssueAlertsEnabled({
diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx
index f029a0710..c80995dc1 100644
--- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx
+++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx
@@ -64,6 +64,15 @@ import {
SCHEDULE_ONLY_AUTOMATION_UI_DEFINITIONS,
} from './ScheduleOnlyAutomationContent';
import { CustomAutomationsSection } from './CustomAutomationsSection';
+import {
+ buildAutomationDiscordDestinationOptions,
+ buildManagerSlackChannelOptions,
+ DISCORD_DESTINATION_OPTION_PREFIX,
+ isManagerChannelSelectionDisabled,
+ shouldShowManagerSlackChannelWarning,
+ type SlackChannelOption,
+} from './channelOptions';
+import { ManagerChannelEditor } from './ManagerChannelEditor';
import { SlackChannelSelect } from './SlackChannelSelect';
import {
@@ -94,18 +103,15 @@ import {
Megaphone,
Play,
Plus,
- RefreshCcw,
Search,
Select,
SelectContent,
SelectItem,
- SelectSeparator,
SelectTrigger,
SelectValue,
Smile,
MessagesSquare,
Skeleton,
- SquarePen,
Slack,
Spinner,
Settings2,
@@ -211,23 +217,10 @@ const SLACK_TO_DISCORD_DESTINATION_FIELDS = Object.fromEntries(
descriptor.discordField,
]),
) as Record;
-/**
- * Discord options share the Slack destination combobox, so their option ids
- * are prefixed to distinguish them from (unprefixed) Slack channel ids.
- */
-export const DISCORD_DESTINATION_OPTION_PREFIX = 'discord:';
/** Synthetic option id for Suggest Ideas Telegram sticky-topic destination. */
const TELEGRAM_DESTINATION_OPTION = 'telegram:primary';
const TEAMS_DESTINATION_OPTION = 'teams:primary';
-type SlackChannelOption = {
- id: string;
- name: string;
- label: string;
- isPrivate?: boolean;
- isMember?: boolean | null;
-};
-
type AutomationDefinition = {
id: AutomationId;
label: string;
@@ -289,9 +282,6 @@ type AutomationStatusSummary = {
lastError: string | null;
};
-const CUSTOM_MANAGER_CHANNEL_SELECT_VALUE = '__custom_manager_channel__';
-const CLEAR_MANAGER_CHANNEL_SELECT_VALUE = '__clear_manager_channel__';
-
const EMPTY_SLACK_CHANNEL_ACCESS_WARNINGS: SlackChannelAccessWarnings = {
channelAutoStartSlackChannels: [],
managerSlackChannel: null,
@@ -1021,169 +1011,6 @@ export function getAutomationRunTooltip({
return 'Run now';
}
-export function shouldShowManagerSlackChannelWarning({
- formValue,
- savedChannelId,
- warningChannelId,
- isDirty,
-}: {
- formValue: string | null | undefined;
- savedChannelId: string | null | undefined;
- warningChannelId: string | null | undefined;
- isDirty: boolean;
-}): boolean {
- const trimmedFormValue = formValue?.trim();
-
- if (!trimmedFormValue || !warningChannelId) {
- return false;
- }
-
- if (warningChannelId.toLowerCase() === trimmedFormValue.toLowerCase()) {
- return true;
- }
-
- return !isDirty && savedChannelId === warningChannelId;
-}
-
-export function formatSlackChannelValue(
- value: string | null | undefined,
-): string {
- const trimmedValue = value?.trim() ?? '';
-
- if (!trimmedValue) {
- return '';
- }
-
- if (trimmedValue.startsWith('#') || /^[CGD][A-Z0-9]+$/i.test(trimmedValue)) {
- return trimmedValue;
- }
-
- return `#${trimmedValue}`;
-}
-
-function matchesSlackChannelOption(
- value: string | null | undefined,
- option: SlackChannelOption,
-): boolean {
- const normalizedValue = value?.trim().toLowerCase();
-
- if (!normalizedValue) {
- return false;
- }
-
- return (
- normalizedValue === option.id.toLowerCase() ||
- normalizedValue === option.name.toLowerCase() ||
- normalizedValue === option.label.toLowerCase()
- );
-}
-
-export function buildManagerSlackChannelOptions(params: {
- channels: Array<{ id: string; name: string }>;
- selectedValue: string | null | undefined;
-}): SlackChannelOption[] {
- const options = params.channels.map((channel) => ({
- id: channel.id,
- name: channel.name,
- label: `#${channel.name}`,
- }));
-
- const selectedValue = params.selectedValue?.trim();
- if (
- !selectedValue ||
- options.some((option) => matchesSlackChannelOption(selectedValue, option))
- ) {
- return options;
- }
-
- return [
- {
- id: selectedValue,
- name: selectedValue.startsWith('#')
- ? selectedValue.slice(1)
- : selectedValue,
- label: formatSlackChannelValue(selectedValue),
- },
- ...options,
- ];
-}
-
-export function buildAutomationDiscordDestinationOptions(params: {
- channels: Array<{ id: string; name: string; label: string }>;
- selectedChannelId: string | null | undefined;
- /**
- * The "(Discord)" suffix only disambiguates when Slack channels can appear
- * in the same picker; on a Discord-only deployment it is noise.
- */
- includeProviderSuffix: boolean;
-}): SlackChannelOption[] {
- const suffix = params.includeProviderSuffix ? ' (Discord)' : '';
- const options = params.channels.map((channel) => ({
- id: `${DISCORD_DESTINATION_OPTION_PREFIX}${channel.id}`,
- name: channel.name,
- label: `${channel.label}${suffix}`,
- }));
-
- const selectedChannelId = params.selectedChannelId?.trim();
- const selectedOptionId = selectedChannelId
- ? `${DISCORD_DESTINATION_OPTION_PREFIX}${selectedChannelId}`
- : null;
-
- if (
- !selectedOptionId ||
- options.some((option) => option.id === selectedOptionId)
- ) {
- return options;
- }
-
- // Keep a saved channel selectable/displayable even when the cached channel
- // catalog no longer lists it (or has not loaded yet).
- return [
- {
- id: selectedOptionId,
- name: selectedChannelId!,
- label: `#${selectedChannelId}${suffix}`,
- },
- ...options,
- ];
-}
-
-export function buildCustomManagerSlackChannelOption(params: {
- searchValue: string | null | undefined;
- options: SlackChannelOption[];
-}): SlackChannelOption | null {
- const searchValue = params.searchValue?.trim();
-
- if (
- !searchValue ||
- params.options.some((option) =>
- matchesSlackChannelOption(searchValue, option),
- )
- ) {
- return null;
- }
-
- const label = formatSlackChannelValue(searchValue);
-
- return {
- id: searchValue,
- name: label.startsWith('#') ? label.slice(1) : label,
- label,
- };
-}
-
-export function isManagerChannelSelectionDisabled(params: {
- slackConnected: boolean;
- isFetching: boolean;
- hasValue: boolean;
- isConfigured: boolean;
-}): boolean {
- return (
- params.isFetching ||
- (!params.slackConnected && !params.hasValue && !params.isConfigured)
- );
-}
-
function hasConfiguredChannelAutoStartRows(
rows: ChannelAutoStartFormRow[] | null | undefined,
): boolean {
@@ -1719,9 +1546,6 @@ export function AutomationsSettings() {
const [openAutomationIds, setOpenAutomationIds] = useState>(
() => new Set(),
);
- const [isEditingManagerChannel, setIsEditingManagerChannel] = useState(false);
- const [isEnteringCustomManagerChannel, setIsEnteringCustomManagerChannel] =
- useState(false);
const [availableCategory, setAvailableCategory] = useState<
AutomationCategory | 'all'
>('all');
@@ -1938,11 +1762,6 @@ export function AutomationsSettings() {
queryKey: trpc.automations.getSettings.queryKey(),
});
- if (savingAutomation === 'managerChannel') {
- setIsEditingManagerChannel(false);
- setIsEnteringCustomManagerChannel(false);
- }
-
toast.success(
automationLabel
? `Saved settings for the ${automationLabel} automation.`
@@ -2118,10 +1937,6 @@ export function AutomationsSettings() {
return;
}
- if (automationId === 'managerChannel') {
- setIsEnteringCustomManagerChannel(false);
- }
-
setFormState(resetAutomationFields(formState, savedState, automationId));
},
[formState, savedState],
@@ -2254,71 +2069,10 @@ export function AutomationsSettings() {
const managerChannelConfigured = Boolean(
managerSlackChannelId || managerDiscordChannelId,
);
- const managerChannelOptions = useMemo(
- () =>
- buildManagerSlackChannelOptions({
- channels: slackChannelsQuery.data?.channels ?? [],
- selectedValue: null,
- }),
- [slackChannelsQuery.data?.channels],
- );
- const selectedManagerChannelOption =
- managerChannelOptions.find((option) =>
- matchesSlackChannelOption(formState?.managerSlackChannel, option),
- ) ?? null;
- const managerDiscordChannelOptions = useMemo(
- () =>
- buildAutomationDiscordDestinationOptions({
- channels: discordChannelsQuery.data?.channels ?? [],
- selectedChannelId: formState?.managerDiscordChannel,
- includeProviderSuffix: capabilities?.slackConnected === true,
- }),
- [
- capabilities?.slackConnected,
- discordChannelsQuery.data?.channels,
- formState?.managerDiscordChannel,
- ],
- );
- const selectedManagerDiscordChannelOption =
- managerDiscordChannelOptions.find(
- (option) =>
- option.id ===
- `${DISCORD_DESTINATION_OPTION_PREFIX}${formState?.managerDiscordChannel}`,
- ) ?? null;
- const managerChannelHasValue = Boolean(
- formState?.managerSlackChannel.trim() ||
- formState?.managerDiscordChannel.trim(),
- );
- const showCustomManagerChannelInput =
- isEnteringCustomManagerChannel ||
- (managerChannelHasValue &&
- !selectedManagerChannelOption &&
- !selectedManagerDiscordChannelOption);
const slackChannelChoices = useMemo(
() => slackChannelsQuery.data?.channels ?? [],
[slackChannelsQuery.data?.channels],
);
- const managerChannelSelectionDisabled = isManagerChannelSelectionDisabled({
- slackConnected:
- capabilities?.slackConnected === true ||
- capabilities?.discordConnected === true,
- isFetching:
- slackChannelsQuery.isFetching || discordChannelsQuery.isFetching,
- hasValue: managerChannelHasValue,
- isConfigured: managerChannelConfigured,
- });
- const managerChannelSelectLabel = showCustomManagerChannelInput
- ? formatSlackChannelValue(formState?.managerSlackChannel) ||
- 'Private or manual channel'
- : selectedManagerDiscordChannelOption?.label ||
- selectedManagerChannelOption?.label ||
- (capabilities?.discordConnected
- ? 'Select a channel'
- : 'Select a Slack channel');
- const managerChannelSelectValue = showCustomManagerChannelInput
- ? CUSTOM_MANAGER_CHANNEL_SELECT_VALUE
- : (selectedManagerDiscordChannelOption?.id ??
- selectedManagerChannelOption?.id);
const managerStatsIsEnabled = formState?.managerStatsFrequency !== 'off';
const sentryConnected = capabilities?.sentryConnected === true;
const sentryTriageIsEnabled = formState?.sentryTriageFrequency !== 'off';
@@ -2333,12 +2087,6 @@ export function AutomationsSettings() {
});
const suggesterIsEnabled = formState?.suggesterFrequency !== 'off';
const announcerIsEnabled = formState?.announcerFrequency !== 'off';
- const showManagerSlackChannelWarning = shouldShowManagerSlackChannelWarning({
- formValue: formState?.managerSlackChannel,
- savedChannelId: managerSlackChannelId,
- warningChannelId: slackChannelAccessWarnings.managerSlackChannel,
- isDirty: isDirty.managerChannel,
- });
const showChannelAutoStartSlackChannelWarning =
shouldShowChannelAutoStartWarning({
// Access warnings are a Slack concept (the bot must be invited);
@@ -2692,34 +2440,6 @@ export function AutomationsSettings() {
null,
]),
) as Record;
- const savedManagerChannelLabel = (() => {
- if (managerDiscordChannelId) {
- const channelFromList = discordChannelsQuery.data?.channels.find(
- (channel) => channel.id === managerDiscordChannelId,
- );
- return channelFromList
- ? `${channelFromList.label} (Discord)`
- : `#${managerDiscordChannelId} (Discord)`;
- }
-
- const channelFromList = slackChannelChoices.find(
- (channel) => channel.id === managerSlackChannelId,
- );
- if (channelFromList) {
- return `#${channelFromList.name}`;
- }
-
- const value = formatSlackChannelValue(savedState?.managerSlackChannel);
- if (!value) {
- return '#channel';
- }
- return value;
- })();
- const showManagerChannelForm =
- !managerChannelConfigured ||
- isEditingManagerChannel ||
- isDirty.managerChannel;
-
return (
{!settingsQuery.isPending &&
@@ -3854,266 +3574,62 @@ export function AutomationsSettings() {
onOpenChange={(open) => setAutomationOpen('managerChannel', open)}
iconEnabled={iconEnabled.managerChannel}
>
-
- {showManagerChannelForm ? (
- <>
-
- Where should Roomote post manager-facing updates?
-
-
- Make sure the Roomote app is added to the channel.
-
-
-
- {
- if (value === CLEAR_MANAGER_CHANNEL_SELECT_VALUE) {
- setIsEnteringCustomManagerChannel(false);
- setFormState((prev) =>
- prev
- ? {
- ...prev,
- managerSlackChannel: '',
- managerDiscordChannel: '',
- }
- : prev,
- );
- return;
- }
-
- if (value === CUSTOM_MANAGER_CHANNEL_SELECT_VALUE) {
- setIsEnteringCustomManagerChannel(true);
- setFormState((prev) =>
- prev && selectedManagerChannelOption
- ? {
- ...prev,
- managerSlackChannel: '',
- managerDiscordChannel: '',
- }
- : prev,
- );
- return;
- }
-
- if (
- value.startsWith(
- DISCORD_DESTINATION_OPTION_PREFIX,
- )
- ) {
- setIsEnteringCustomManagerChannel(false);
- setFormState((prev) =>
- prev
- ? {
- ...prev,
- managerSlackChannel: '',
- managerDiscordChannel: value.slice(
- DISCORD_DESTINATION_OPTION_PREFIX.length,
- ),
- }
- : prev,
- );
- return;
- }
-
- const selectedChannel = managerChannelOptions.find(
- (channel) => channel.id === value,
- );
-
- if (!selectedChannel) {
- return;
- }
-
- setIsEnteringCustomManagerChannel(false);
- setFormState((prev) =>
- prev
- ? {
- ...prev,
- managerSlackChannel: selectedChannel.label,
- managerDiscordChannel: '',
- }
- : prev,
- );
- }}
- disabled={managerChannelSelectionDisabled}
- >
-
-
- {managerChannelSelectLabel}
-
-
-
- {managerChannelHasValue ? (
- <>
-
- Clear selection
-
-
- >
- ) : null}
- {slackChannelsQuery.isPending ||
- discordChannelsQuery.isPending ? (
-
- Loading channels...
-
- ) : slackChannelsQuery.isError ||
- discordChannelsQuery.isError ? (
-
- Could not load channels. Try refreshing.
-
- ) : managerChannelOptions.length > 0 ||
- managerDiscordChannelOptions.length > 0 ? (
- [
- ...managerChannelOptions,
- ...managerDiscordChannelOptions,
- ].map((channel) => (
-
- {channel.label}
-
- ))
- ) : (
-
- No channels found.
-
- )}
-
-
- Private or manual channel
-
-
-
- {managerChannelOptions.length > 0 ||
- managerDiscordChannelOptions.length > 0 ? (
- {
- void Promise.all([
- slackChannelsQuery.refetch(),
- discordChannelsQuery.refetch(),
- ]);
- }}
- >
-
-
- ) : null}
-
- {showCustomManagerChannelInput ? (
-
{
- setIsEnteringCustomManagerChannel(true);
- setFormState((prev) =>
- prev
- ? {
- ...prev,
- managerSlackChannel: event.target.value,
- managerDiscordChannel: '',
- }
- : prev,
- );
- }}
- placeholder="Enter a private channel name or Slack channel ID"
- autoCapitalize="off"
- autoCorrect="off"
- spellCheck={false}
- />
- ) : null}
-
-
- Private channels may not appear in the list. Use the
- manual option to paste a private channel name or raw Slack
- channel ID.
-
- {showManagerSlackChannelWarning ? (
-
- ) : null}
- {fieldErrors.managerSlackChannel ||
- fieldErrors.managerDiscordChannel ? (
-
- {fieldErrors.managerSlackChannel ??
- fieldErrors.managerDiscordChannel}
-
- ) : null}
- {showManagerChannelMigrationNote ? (
-
-
- Some older automations still point at different Slack
- channels. Pick the shared Manager Channel here to
- migrate future manager-facing posts onto one
- destination.
-
-
- ) : null}
-
-
+ setFormState((prev) =>
+ prev
+ ? {
+ ...prev,
+ managerSlackChannel: slackChannel,
+ managerDiscordChannel: discordChannel,
}
- onSave={() => saveAgent('managerChannel')}
- onReset={() => {
- resetAgent('managerChannel');
- if (managerChannelConfigured) {
- setIsEditingManagerChannel(false);
- }
- }}
- />
- {managerChannelConfigured &&
- isEditingManagerChannel &&
- !isDirty.managerChannel ? (
- setIsEditingManagerChannel(false)}
- >
- Cancel
-
- ) : null}
-
- >
- ) : (
-
- Posting manager-facing updates to{' '}
- setIsEditingManagerChannel(true)}
- >
- {savedManagerChannelLabel}
-
-
-
- )}
-
+ : prev,
+ )
+ }
+ onRefresh={() => {
+ void Promise.all([
+ slackChannelsQuery.refetch(),
+ discordChannelsQuery.refetch(),
+ ]);
+ }}
+ onSave={() => saveAgent('managerChannel')}
+ onReset={() => resetAgent('managerChannel')}
+ />
{
+ it('keeps refresh available when the channel catalog fails to load', () => {
+ const onRefresh = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh channels' }));
+ expect(onRefresh).toHaveBeenCalledOnce();
+ });
+
+ it('closes the editor after a successful save transition', async () => {
+ const { rerender } = render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /#roomote-managers/ }));
+ expect(screen.getByLabelText('Select manager channel')).toBeInTheDocument();
+
+ rerender( );
+ rerender( );
+
+ await waitFor(() => {
+ expect(
+ screen.queryByLabelText('Select manager channel'),
+ ).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/components/settings/automations/ManagerChannelEditor.tsx b/apps/web/src/components/settings/automations/ManagerChannelEditor.tsx
new file mode 100644
index 000000000..b1a2c2dd7
--- /dev/null
+++ b/apps/web/src/components/settings/automations/ManagerChannelEditor.tsx
@@ -0,0 +1,404 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+import { cn } from '@/lib/utils';
+import {
+ Alert,
+ AlertDescription,
+ Button,
+ Check,
+ Input,
+ Label,
+ RefreshCcw,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ Spinner,
+ SquarePen,
+ TriangleAlert,
+} from '@/components/system';
+
+import {
+ buildAutomationDiscordDestinationOptions,
+ buildManagerSlackChannelOptions,
+ DISCORD_DESTINATION_OPTION_PREFIX,
+ formatSlackChannelValue,
+ isManagerChannelSelectionDisabled,
+ matchesSlackChannelOption,
+ shouldShowManagerSlackChannelWarning,
+} from './channelOptions';
+
+const CUSTOM_MANAGER_CHANNEL_SELECT_VALUE = '__custom_manager_channel__';
+const CLEAR_MANAGER_CHANNEL_SELECT_VALUE = '__clear_manager_channel__';
+
+type SlackChannel = { id: string; name: string };
+type DiscordChannel = { id: string; name: string; label: string };
+
+type ManagerChannelValue = {
+ slackChannel: string;
+ discordChannel: string;
+};
+
+export function ManagerChannelEditor({
+ value,
+ savedSlackChannel,
+ savedSlackChannelId,
+ savedDiscordChannelId,
+ slackChannels,
+ discordChannels,
+ slackConnected,
+ discordConnected,
+ channelsPending,
+ channelsFetching,
+ channelsError,
+ isDirty,
+ isSaving,
+ warningChannelId,
+ slackAppMention,
+ fieldError,
+ showMigrationNote,
+ onChange,
+ onRefresh,
+ onSave,
+ onReset,
+}: {
+ value: ManagerChannelValue;
+ savedSlackChannel: string;
+ savedSlackChannelId: string | null;
+ savedDiscordChannelId: string | null;
+ slackChannels: SlackChannel[];
+ discordChannels: DiscordChannel[];
+ slackConnected: boolean;
+ discordConnected: boolean;
+ channelsPending: boolean;
+ channelsFetching: boolean;
+ channelsError: boolean;
+ isDirty: boolean;
+ isSaving: boolean;
+ warningChannelId: string | null;
+ slackAppMention: string;
+ fieldError?: string;
+ showMigrationNote: boolean;
+ onChange: (value: ManagerChannelValue) => void;
+ onRefresh: () => void;
+ onSave: () => void;
+ onReset: () => void;
+}) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [isEnteringCustomChannel, setIsEnteringCustomChannel] = useState(false);
+ const wasSaving = useRef(false);
+
+ const configured = Boolean(savedSlackChannelId || savedDiscordChannelId);
+ const hasValue = Boolean(
+ value.slackChannel.trim() || value.discordChannel.trim(),
+ );
+ const slackOptions = useMemo(
+ () =>
+ buildManagerSlackChannelOptions({
+ channels: slackChannels,
+ selectedValue: null,
+ }),
+ [slackChannels],
+ );
+ const selectedSlackOption =
+ slackOptions.find((option) =>
+ matchesSlackChannelOption(value.slackChannel, option),
+ ) ?? null;
+ const discordOptions = useMemo(
+ () =>
+ buildAutomationDiscordDestinationOptions({
+ channels: discordChannels,
+ selectedChannelId: value.discordChannel,
+ includeProviderSuffix: slackConnected,
+ }),
+ [discordChannels, slackConnected, value.discordChannel],
+ );
+ const selectedDiscordOption =
+ discordOptions.find(
+ (option) =>
+ option.id ===
+ `${DISCORD_DESTINATION_OPTION_PREFIX}${value.discordChannel}`,
+ ) ?? null;
+ const showCustomInput =
+ isEnteringCustomChannel ||
+ (hasValue && !selectedSlackOption && !selectedDiscordOption);
+ const selectionDisabled = isManagerChannelSelectionDisabled({
+ slackConnected: slackConnected || discordConnected,
+ isFetching: channelsFetching,
+ hasValue,
+ isConfigured: configured,
+ });
+ const selectLabel = showCustomInput
+ ? formatSlackChannelValue(value.slackChannel) || 'Private or manual channel'
+ : selectedDiscordOption?.label ||
+ selectedSlackOption?.label ||
+ (discordConnected ? 'Select a channel' : 'Select a Slack channel');
+ const selectValue = showCustomInput
+ ? CUSTOM_MANAGER_CHANNEL_SELECT_VALUE
+ : (selectedDiscordOption?.id ?? selectedSlackOption?.id);
+ const showWarning = shouldShowManagerSlackChannelWarning({
+ formValue: value.slackChannel,
+ savedChannelId: savedSlackChannelId,
+ warningChannelId,
+ isDirty,
+ });
+ const savedLabel = getSavedManagerChannelLabel({
+ savedSlackChannel,
+ savedSlackChannelId,
+ savedDiscordChannelId,
+ slackChannels,
+ discordChannels,
+ });
+ const showForm = !configured || isEditing || isDirty;
+
+ useEffect(() => {
+ if (wasSaving.current && !isSaving && !isDirty && configured) {
+ setIsEditing(false);
+ setIsEnteringCustomChannel(false);
+ }
+ wasSaving.current = isSaving;
+ }, [configured, isDirty, isSaving]);
+
+ if (!showForm) {
+ return (
+
+ Posting manager-facing updates to{' '}
+ setIsEditing(true)}
+ >
+ {savedLabel}
+
+
+
+ );
+ }
+
+ return (
+
+
+ Where should Roomote post manager-facing updates?
+
+
+ Make sure the Roomote app is added to the channel.
+
+
+
+ {
+ if (nextValue === CLEAR_MANAGER_CHANNEL_SELECT_VALUE) {
+ setIsEnteringCustomChannel(false);
+ onChange({ slackChannel: '', discordChannel: '' });
+ return;
+ }
+
+ if (nextValue === CUSTOM_MANAGER_CHANNEL_SELECT_VALUE) {
+ setIsEnteringCustomChannel(true);
+ if (selectedSlackOption) {
+ onChange({ slackChannel: '', discordChannel: '' });
+ }
+ return;
+ }
+
+ if (nextValue.startsWith(DISCORD_DESTINATION_OPTION_PREFIX)) {
+ setIsEnteringCustomChannel(false);
+ onChange({
+ slackChannel: '',
+ discordChannel: nextValue.slice(
+ DISCORD_DESTINATION_OPTION_PREFIX.length,
+ ),
+ });
+ return;
+ }
+
+ const selectedChannel = slackOptions.find(
+ (channel) => channel.id === nextValue,
+ );
+ if (selectedChannel) {
+ setIsEnteringCustomChannel(false);
+ onChange({
+ slackChannel: selectedChannel.label,
+ discordChannel: '',
+ });
+ }
+ }}
+ disabled={selectionDisabled}
+ >
+
+ {selectLabel}
+
+
+ {hasValue ? (
+ <>
+
+ Clear selection
+
+
+ >
+ ) : null}
+ {channelsPending ? (
+
+ Loading channels...
+
+ ) : channelsError ? (
+
+ Could not load channels. Try refreshing.
+
+ ) : slackOptions.length > 0 || discordOptions.length > 0 ? (
+ [...slackOptions, ...discordOptions].map((channel) => (
+
+ {channel.label}
+
+ ))
+ ) : (
+
+ No channels found.
+
+ )}
+
+
+ Private or manual channel
+
+
+
+ {slackConnected || discordConnected ? (
+
+
+
+ ) : null}
+
+ {showCustomInput ? (
+
{
+ setIsEnteringCustomChannel(true);
+ onChange({
+ slackChannel: event.target.value,
+ discordChannel: '',
+ });
+ }}
+ placeholder="Enter a private channel name or Slack channel ID"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck={false}
+ />
+ ) : null}
+
+
+ Private channels may not appear in the list. Use the manual option to
+ paste a private channel name or raw Slack channel ID.
+
+ {showWarning ? (
+
+
+ Make sure {slackAppMention} is added to that channel.
+
+ ) : null}
+ {fieldError ? (
+
{fieldError}
+ ) : null}
+ {showMigrationNote ? (
+
+
+ Some older automations still point at different Slack channels. Pick
+ the shared Manager Channel here to migrate future manager-facing
+ posts onto one destination.
+
+
+ ) : null}
+
+ {isDirty || isSaving ? (
+
+ {
+ onReset();
+ if (configured) {
+ setIsEditing(false);
+ setIsEnteringCustomChannel(false);
+ }
+ }}
+ disabled={isSaving}
+ >
+ Reset
+
+
+ {isSaving ? (
+ <>
+
+ Saving...
+
+ >
+ ) : (
+ <>
+ Save
+ >
+ )}
+
+
+ ) : null}
+ {configured && isEditing && !isDirty ? (
+
setIsEditing(false)}
+ >
+ Cancel
+
+ ) : null}
+
+
+ );
+}
+
+function getSavedManagerChannelLabel({
+ savedSlackChannel,
+ savedSlackChannelId,
+ savedDiscordChannelId,
+ slackChannels,
+ discordChannels,
+}: {
+ savedSlackChannel: string;
+ savedSlackChannelId: string | null;
+ savedDiscordChannelId: string | null;
+ slackChannels: SlackChannel[];
+ discordChannels: DiscordChannel[];
+}) {
+ if (savedDiscordChannelId) {
+ const channel = discordChannels.find(
+ (option) => option.id === savedDiscordChannelId,
+ );
+ return channel
+ ? `${channel.label} (Discord)`
+ : `#${savedDiscordChannelId} (Discord)`;
+ }
+
+ const channel = slackChannels.find(
+ (option) => option.id === savedSlackChannelId,
+ );
+ if (channel) {
+ return `#${channel.name}`;
+ }
+
+ return formatSlackChannelValue(savedSlackChannel) || '#channel';
+}
diff --git a/apps/web/src/components/settings/automations/channelOptions.client.test.ts b/apps/web/src/components/settings/automations/channelOptions.client.test.ts
new file mode 100644
index 000000000..b44a62b57
--- /dev/null
+++ b/apps/web/src/components/settings/automations/channelOptions.client.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildAutomationDiscordDestinationOptions,
+ buildManagerSlackChannelOptions,
+ DISCORD_DESTINATION_OPTION_PREFIX,
+ formatSlackChannelValue,
+ isManagerChannelSelectionDisabled,
+ shouldShowManagerSlackChannelWarning,
+} from './channelOptions';
+
+describe('manager channel options', () => {
+ it('shows saved access warnings but ignores stale warnings during edits', () => {
+ expect(
+ shouldShowManagerSlackChannelWarning({
+ formValue: '#roomote-managers',
+ savedChannelId: 'C123MANAGER',
+ warningChannelId: 'C123MANAGER',
+ isDirty: false,
+ }),
+ ).toBe(true);
+ expect(
+ shouldShowManagerSlackChannelWarning({
+ formValue: '#other-channel',
+ savedChannelId: 'C123MANAGER',
+ warningChannelId: 'C123MANAGER',
+ isDirty: true,
+ }),
+ ).toBe(false);
+ });
+
+ it('formats Slack channel names and ids for display', () => {
+ expect(formatSlackChannelValue('roomote-managers')).toBe(
+ '#roomote-managers',
+ );
+ expect(formatSlackChannelValue('#roomote-managers')).toBe(
+ '#roomote-managers',
+ );
+ expect(formatSlackChannelValue('C123MANAGER')).toBe('C123MANAGER');
+ });
+
+ it('preserves missing selections without duplicating fetched options', () => {
+ expect(
+ buildManagerSlackChannelOptions({
+ channels: [{ id: 'C456', name: 'engineering' }],
+ selectedValue: '#roomote-managers',
+ }),
+ ).toEqual([
+ {
+ id: '#roomote-managers',
+ name: 'roomote-managers',
+ label: '#roomote-managers',
+ },
+ { id: 'C456', name: 'engineering', label: '#engineering' },
+ ]);
+ expect(
+ buildManagerSlackChannelOptions({
+ channels: [{ id: 'C123MANAGER', name: 'roomote-managers' }],
+ selectedValue: '#roomote-managers',
+ }),
+ ).toHaveLength(1);
+ });
+
+ it('prefixes Discord options and preserves missing saved channels', () => {
+ expect(
+ buildAutomationDiscordDestinationOptions({
+ channels: [{ id: '111', name: 'general', label: '#general' }],
+ selectedChannelId: '222',
+ includeProviderSuffix: true,
+ }),
+ ).toEqual([
+ {
+ id: `${DISCORD_DESTINATION_OPTION_PREFIX}222`,
+ name: '222',
+ label: '#222 (Discord)',
+ },
+ {
+ id: `${DISCORD_DESTINATION_OPTION_PREFIX}111`,
+ name: 'general',
+ label: '#general (Discord)',
+ },
+ ]);
+ });
+
+ it('omits the Discord suffix when no other provider needs disambiguation', () => {
+ expect(
+ buildAutomationDiscordDestinationOptions({
+ channels: [{ id: '111', name: 'general', label: '#general' }],
+ selectedChannelId: '111',
+ includeProviderSuffix: false,
+ }),
+ ).toEqual([
+ {
+ id: `${DISCORD_DESTINATION_OPTION_PREFIX}111`,
+ name: 'general',
+ label: '#general',
+ },
+ ]);
+ });
+
+ it('allows configured values when the provider is disconnected', () => {
+ expect(
+ isManagerChannelSelectionDisabled({
+ slackConnected: false,
+ isFetching: false,
+ hasValue: true,
+ isConfigured: true,
+ }),
+ ).toBe(false);
+ expect(
+ isManagerChannelSelectionDisabled({
+ slackConnected: false,
+ isFetching: false,
+ hasValue: false,
+ isConfigured: false,
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/apps/web/src/components/settings/automations/channelOptions.ts b/apps/web/src/components/settings/automations/channelOptions.ts
new file mode 100644
index 000000000..18c1b1e21
--- /dev/null
+++ b/apps/web/src/components/settings/automations/channelOptions.ts
@@ -0,0 +1,142 @@
+export const DISCORD_DESTINATION_OPTION_PREFIX = 'discord:';
+
+export type SlackChannelOption = {
+ id: string;
+ name: string;
+ label: string;
+ isPrivate?: boolean;
+ isMember?: boolean | null;
+};
+
+export function formatSlackChannelValue(
+ value: string | null | undefined,
+): string {
+ const trimmedValue = value?.trim() ?? '';
+
+ if (!trimmedValue) {
+ return '';
+ }
+
+ if (trimmedValue.startsWith('#') || /^[CGD][A-Z0-9]+$/i.test(trimmedValue)) {
+ return trimmedValue;
+ }
+
+ return `#${trimmedValue}`;
+}
+
+export function matchesSlackChannelOption(
+ value: string | null | undefined,
+ option: SlackChannelOption,
+): boolean {
+ const normalizedValue = value?.trim().toLowerCase();
+
+ if (!normalizedValue) {
+ return false;
+ }
+
+ return (
+ normalizedValue === option.id.toLowerCase() ||
+ normalizedValue === option.name.toLowerCase() ||
+ normalizedValue === option.label.toLowerCase()
+ );
+}
+
+export function buildManagerSlackChannelOptions(params: {
+ channels: Array<{ id: string; name: string }>;
+ selectedValue: string | null | undefined;
+}): SlackChannelOption[] {
+ const options = params.channels.map((channel) => ({
+ id: channel.id,
+ name: channel.name,
+ label: `#${channel.name}`,
+ }));
+
+ const selectedValue = params.selectedValue?.trim();
+ if (
+ !selectedValue ||
+ options.some((option) => matchesSlackChannelOption(selectedValue, option))
+ ) {
+ return options;
+ }
+
+ return [
+ {
+ id: selectedValue,
+ name: selectedValue.startsWith('#')
+ ? selectedValue.slice(1)
+ : selectedValue,
+ label: formatSlackChannelValue(selectedValue),
+ },
+ ...options,
+ ];
+}
+
+export function buildAutomationDiscordDestinationOptions(params: {
+ channels: Array<{ id: string; name: string; label: string }>;
+ selectedChannelId: string | null | undefined;
+ includeProviderSuffix: boolean;
+}): SlackChannelOption[] {
+ const suffix = params.includeProviderSuffix ? ' (Discord)' : '';
+ const options = params.channels.map((channel) => ({
+ id: `${DISCORD_DESTINATION_OPTION_PREFIX}${channel.id}`,
+ name: channel.name,
+ label: `${channel.label}${suffix}`,
+ }));
+
+ const selectedChannelId = params.selectedChannelId?.trim();
+ const selectedOptionId = selectedChannelId
+ ? `${DISCORD_DESTINATION_OPTION_PREFIX}${selectedChannelId}`
+ : null;
+
+ if (
+ !selectedOptionId ||
+ options.some((option) => option.id === selectedOptionId)
+ ) {
+ return options;
+ }
+
+ return [
+ {
+ id: selectedOptionId,
+ name: selectedChannelId!,
+ label: `#${selectedChannelId}${suffix}`,
+ },
+ ...options,
+ ];
+}
+
+export function isManagerChannelSelectionDisabled(params: {
+ slackConnected: boolean;
+ isFetching: boolean;
+ hasValue: boolean;
+ isConfigured: boolean;
+}): boolean {
+ return (
+ params.isFetching ||
+ (!params.slackConnected && !params.hasValue && !params.isConfigured)
+ );
+}
+
+export function shouldShowManagerSlackChannelWarning({
+ formValue,
+ savedChannelId,
+ warningChannelId,
+ isDirty,
+}: {
+ formValue: string | null | undefined;
+ savedChannelId: string | null | undefined;
+ warningChannelId: string | null | undefined;
+ isDirty: boolean;
+}): boolean {
+ const trimmedFormValue = formValue?.trim();
+
+ if (!trimmedFormValue || !warningChannelId) {
+ return false;
+ }
+
+ if (warningChannelId.toLowerCase() === trimmedFormValue.toLowerCase()) {
+ return true;
+ }
+
+ return !isDirty && savedChannelId === warningChannelId;
+}
From 74b080df5dfad942c9b94f754ea240cd6d777860 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 11:44:22 +0100
Subject: [PATCH 04/13] [Docs] Add cloud agent adoption recipe (#1064)
* docs: add cloud agent adoption recipe
* docs: clarify cookbook recipe workflow
---------
Co-authored-by: Roomote
---
apps/docs/AGENTS.md | 15 ++-
.../ease-your-team-into-cloud-agents.mdx | 97 +++++++++++++++++++
apps/docs/cookbook/index.mdx | 1 +
apps/docs/scripts/generate-cookbook-index.mjs | 4 +-
4 files changed, 115 insertions(+), 2 deletions(-)
create mode 100644 apps/docs/cookbook/ease-your-team-into-cloud-agents.mdx
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/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/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)} |`;
});
From ddebdcf86a62ff75fe3948aad2dce2753c10a05b Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 13:24:45 +0100
Subject: [PATCH 05/13] [Improve] Make routine Discord release announcements
more compact (#1085)
* improve: compact Discord release announcements
* refactor: split Discord release message builders
---------
Co-authored-by: Roomote
---
.../__tests__/discord-release.test.mjs | 71 +++++++++++++-
scripts/release/lib.mjs | 96 ++++++++++++++-----
2 files changed, 139 insertions(+), 28 deletions(-)
diff --git a/scripts/release/__tests__/discord-release.test.mjs b/scripts/release/__tests__/discord-release.test.mjs
index d43d90e96..afc1fc4d1 100644
--- a/scripts/release/__tests__/discord-release.test.mjs
+++ b/scripts/release/__tests__/discord-release.test.mjs
@@ -3,11 +3,13 @@ import { describe, it } from 'node:test';
import { buildDiscordReleasePayload } from '../lib.mjs';
describe('Discord release announcement', () => {
- it('creates a regular announcement without patch changes', () => {
+ it('creates a compact patch announcement from the release intro', () => {
const url = 'https://github.com/RooCodeInc/Roomote/releases/tag/v0.24.1';
const payload = buildDiscordReleasePayload({
name: '0.24.1 (2026-07-29)',
body: [
+ '## 0.24.1 (2026-07-29)',
+ '',
'A useful release.',
'',
'### Highlights',
@@ -29,18 +31,77 @@ describe('Discord release announcement', () => {
assert.equal(
payload.content,
[
- '# Roomote 0.24.1 is out!',
+ '### Roomote 0.24.1 is published',
'',
'A useful release.',
'',
+ `See the full release notes → [v0.24.1](${url}).`,
+ ].join('\n'),
+ );
+ assert.equal('embeds' in payload, false);
+ });
+
+ it('creates a compact minor announcement', () => {
+ const url = 'https://github.com/RooCodeInc/Roomote/releases/tag/v0.25.0';
+ const payload = buildDiscordReleasePayload({
+ body: [
+ '## 0.25.0 (2026-07-30)',
+ '',
+ 'A focused minor release.',
+ '',
'### Highlights',
'',
'- A useful new capability',
+ ].join('\n'),
+ url,
+ tagName: 'v0.25.0',
+ });
+
+ assert.equal(
+ payload.content,
+ [
+ '### Roomote 0.25.0 is published',
+ '',
+ 'A focused minor release.',
'',
- `See the full release notes [v0.24.1](${url}). Let us know what you think!`,
+ `See the full release notes → [v0.25.0](${url}).`,
+ ].join('\n'),
+ );
+ });
+
+ it('creates a detailed major announcement without redundant spacing', () => {
+ const url = 'https://github.com/RooCodeInc/Roomote/releases/tag/v1.0.0';
+ const payload = buildDiscordReleasePayload({
+ body: [
+ '## 1.0.0 (2026-08-01)',
+ '',
+ 'A major release.',
+ '',
+ '### Highlights',
+ '',
+ '- A useful new capability',
+ '',
+ '### Patch changes',
+ '',
+ '- Internal patch details',
+ ].join('\n'),
+ url,
+ tagName: 'v1.0.0',
+ });
+
+ assert.equal(
+ payload.content,
+ [
+ '# Roomote 1.0.0 is out!',
+ '',
+ 'A major release.',
+ '### Highlights',
+ '',
+ '- A useful new capability',
+ '',
+ `See the full release notes → [v1.0.0](${url}). Let us know what you think!`,
].join('\n'),
);
- assert.equal('embeds' in payload, false);
});
it('truncates long notes while keeping the release link', () => {
@@ -55,7 +116,7 @@ describe('Discord release announcement', () => {
assert.match(
payload.content,
new RegExp(
- `…\\n\\nSee the full release notes \\[v0\\.24\\.1\\]\\(${url}\\)\\. Let us know what you think!$`,
+ `See the full release notes → \\[v0\\.24\\.1\\]\\(${url}\\)\\.$`,
),
);
});
diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs
index a7e5eb363..2b2ac2e3f 100644
--- a/scripts/release/lib.mjs
+++ b/scripts/release/lib.mjs
@@ -187,23 +187,76 @@ function truncateDiscordText(value, limit) {
return `${value.slice(0, limit - 1).trimEnd()}…`;
}
-function removePatchChangesSection(markdown) {
+function parseDiscordReleaseNotes(markdown, version) {
const lines = markdown.split('\n');
- const kept = [];
- let skipping = false;
+ const firstLine = lines[0]?.trim().toLowerCase();
+ const normalizedVersion = version.toLowerCase();
+ const headings = [`## ${normalizedVersion}`, `## v${normalizedVersion}`];
+ if (
+ headings.some(
+ (heading) =>
+ firstLine === heading || firstLine?.startsWith(`${heading} (`),
+ )
+ ) {
+ lines.shift();
+ }
+
+ const intro = [];
+ const sections = [];
+ let currentSection = null;
for (const line of lines) {
- if (/^###\s+Patch changes\s*$/i.test(line)) {
- skipping = true;
- continue;
- }
- if (skipping && /^#{1,3}\s+/.test(line)) {
- skipping = false;
+ if (line.startsWith('### ')) {
+ currentSection = { heading: line.slice(4).trim(), lines: [] };
+ sections.push(currentSection);
+ } else if (currentSection) {
+ currentSection.lines.push(line);
+ } else {
+ intro.push(line);
}
- if (!skipping) kept.push(line);
}
- return kept.join('\n').trim();
+ return {
+ intro: intro.join('\n').trim(),
+ sections: sections.map((section) => ({
+ heading: section.heading,
+ body: section.lines.join('\n').trim(),
+ })),
+ };
+}
+
+function assembleDiscordContent(prefix, body, suffix) {
+ const bodyLimit = DISCORD_MESSAGE_LIMIT - prefix.length - suffix.length - 4;
+ const announcementBody = truncateDiscordText(body, bodyLimit);
+ return announcementBody
+ ? `${prefix}\n\n${announcementBody}\n\n${suffix}`
+ : `${prefix}\n\n${suffix}`;
+}
+
+function buildCompactDiscordReleaseContent({ version, url, notes }) {
+ return assembleDiscordContent(
+ `### Roomote ${version} is published`,
+ notes.intro,
+ `See the full release notes → [v${version}](${url}).`,
+ );
+}
+
+function buildDetailedDiscordReleaseContent({ version, url, notes }) {
+ const sections = notes.sections
+ .filter((section) => section.heading.toLowerCase() !== 'patch changes')
+ .map((section) =>
+ section.body
+ ? `### ${section.heading}\n\n${section.body}`
+ : `### ${section.heading}`,
+ );
+ const [firstSection, ...remainingSections] = sections;
+ const body = [notes.intro, firstSection].filter(Boolean).join('\n');
+
+ return assembleDiscordContent(
+ `# Roomote ${version} is out!`,
+ [body, ...remainingSections].filter(Boolean).join('\n\n'),
+ `See the full release notes → [v${version}](${url}). Let us know what you think!`,
+ );
}
/**
@@ -230,18 +283,15 @@ export function buildDiscordReleasePayload(release) {
}
const version = tagName.replace(/^v/i, '');
- const versionTag = `v${version}`;
- const prefix = `# Roomote ${version} is out!`;
- const suffix = `See the full release notes [${versionTag}](${url}). Let us know what you think!`;
- const body =
- typeof release.body === 'string'
- ? removePatchChangesSection(release.body)
- : '';
- const bodyLimit = DISCORD_MESSAGE_LIMIT - prefix.length - suffix.length - 4;
- const announcementBody = truncateDiscordText(body, bodyLimit);
- const content = announcementBody
- ? `${prefix}\n\n${announcementBody}\n\n${suffix}`
- : `${prefix}\n\n${suffix}`;
+ const [, minor, patch] = version.split('-', 1)[0].split('.');
+ const isMajorRelease = minor === '0' && patch === '0';
+ const notes = parseDiscordReleaseNotes(
+ typeof release.body === 'string' ? release.body : '',
+ version,
+ );
+ const content = isMajorRelease
+ ? buildDetailedDiscordReleaseContent({ version, url, notes })
+ : buildCompactDiscordReleaseContent({ version, url, notes });
return {
username: 'Roomote Releases',
From 2e8f899f78e23bbeb1ba2156e0a4d191a331d5f1 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 13:24:57 +0100
Subject: [PATCH 06/13] [Feat] Track setup funnel progress (#1087)
* feat: track setup funnel milestones
* fix: preserve setup milestone detection
---------
Co-authored-by: Roomote
---
apps/docs/anonymous-analytics.mdx | 9 +-
.../(onboarding)/setup/SetupBootstrapFlow.tsx | 14 +-
.../(onboarding)/setup/SetupSignedInFlow.tsx | 12 +-
.../StepCommunicationConnect.client.test.tsx | 50 ++--
.../setup/StepCommunicationConnect.tsx | 48 +++-
.../setup/StepDiscordSetup.client.test.tsx | 18 +-
.../(onboarding)/setup/StepDiscordSetup.tsx | 33 ++-
.../setup/StepTelegramSetup.client.test.tsx | 16 +-
.../(onboarding)/setup/StepTelegramSetup.tsx | 33 ++-
.../lib/server/setup-funnel-telemetry.test.ts | 192 +++++++++++++++
.../src/lib/server/setup-funnel-telemetry.ts | 227 ++++++++++++++++++
.../src/trpc/commands/setup-new/index.test.ts | 87 +++++++
apps/web/src/trpc/commands/setup-new/index.ts | 78 +++++-
apps/web/src/trpc/routers/_app.ts | 27 +++
.../telemetry/src/__tests__/telemetry.test.ts | 11 +
packages/telemetry/src/index.ts | 39 +++
packages/telemetry/src/server/index.ts | 13 +
17 files changed, 858 insertions(+), 49 deletions(-)
create mode 100644 apps/web/src/lib/server/setup-funnel-telemetry.test.ts
create mode 100644 apps/web/src/lib/server/setup-funnel-telemetry.ts
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/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 = (
);
if (provider === 'microsoft') {
- const teamsStatus = teamsIntegrationStatus.data;
const openInTeamsUrl = teamsStatus?.openInTeamsUrl ?? null;
const teamsBotName = teamsStatus?.botName?.trim() || 'Roomote';
- const teamsReady =
- teamsStatus?.botConfigured === true &&
- teamsStatus.microsoftAuthConfigured &&
- openInTeamsUrl !== null;
- const primaryConversationReady = Boolean(
- teamsStatus?.primaryConversationReady,
- );
+ const teamsReady = teamsConfigured && openInTeamsUrl !== null;
return (
diff --git a/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.client.test.tsx
index d4353fe82..db17706eb 100644
--- a/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.client.test.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.client.test.tsx
@@ -47,14 +47,17 @@ vi.mock('@tanstack/react-query', () => ({
isError: false,
}),
useMutation: (options: {
- onSuccess: (result: unknown) => Promise
;
+ onSuccess?: (result: unknown) => Promise;
}) => ({
isPending: false,
- mutate: () =>
- void options.onSuccess({
- telegramWebhook: null,
- discord: { registered: false, error: 'gateway unavailable' },
- }),
+ mutate: () => {
+ if (options.onSuccess) {
+ void options.onSuccess({
+ telegramWebhook: null,
+ discord: { registered: false, error: 'gateway unavailable' },
+ });
+ }
+ },
}),
useQueryClient: () => ({ invalidateQueries: invalidateQueriesMock }),
}));
@@ -71,6 +74,9 @@ vi.mock('@/trpc/client', () => ({
linkedAccounts: {
discord: { queryKey: () => ['linkedAccounts.discord'] },
},
+ setupNew: {
+ trackCommsState: { mutationOptions: () => ({}) },
+ },
}),
}));
diff --git a/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.tsx b/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.tsx
index d778d54e3..fbef92ca2 100644
--- a/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@@ -37,6 +37,11 @@ export function StepDiscordSetup({
const [clearedSavedValues, setClearedSavedValues] = useState<
Record
>({});
+ const configuredTrackedRef = useRef(false);
+ const authedTrackedRef = useRef(false);
+ const trackCommsState = useMutation(
+ trpc.setupNew.trackCommsState.mutationOptions(),
+ );
const provider = useMemo(
() => status.data?.providers.find((item) => item.id === 'discord') ?? null,
[status.data?.providers],
@@ -45,6 +50,11 @@ export function StepDiscordSetup({
const save = useMutation(
trpc.comms.saveAuthConfig.mutationOptions({
onSuccess: async (result) => {
+ setCredentialsSaved(true);
+ configuredTrackedRef.current = true;
+ trackCommsState.mutate({
+ provider: 'discord',
+ });
await Promise.all([
queryClient.invalidateQueries({
queryKey: trpc.comms.status.queryKey(),
@@ -58,12 +68,31 @@ export function StepDiscordSetup({
`Discord was saved, but Roomote could not finish connecting: ${result.discord.error ?? 'unknown error'}`,
);
}
- setCredentialsSaved(true);
},
onError: (error) => toast.error(error.message),
}),
);
const isConfigured = credentialsSaved || provider?.setupSatisfied === true;
+ useEffect(() => {
+ if (
+ provider?.setupSatisfied === true &&
+ !credentialsSaved &&
+ !configuredTrackedRef.current
+ ) {
+ configuredTrackedRef.current = true;
+ trackCommsState.mutate({
+ provider: 'discord',
+ });
+ }
+ }, [credentialsSaved, provider?.setupSatisfied, trackCommsState]);
+ useEffect(() => {
+ if (discordAccount.data?.mapping && !authedTrackedRef.current) {
+ authedTrackedRef.current = true;
+ trackCommsState.mutate({
+ provider: 'discord',
+ });
+ }
+ }, [discordAccount.data?.mapping, trackCommsState]);
const isActionDisabled =
save.isPending ||
status.isLoading ||
diff --git a/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.client.test.tsx
index 69d4a8507..23ad595b2 100644
--- a/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.client.test.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.client.test.tsx
@@ -37,13 +37,16 @@ vi.mock('@tanstack/react-query', () => ({
isError: false,
}),
useMutation: (options: {
- onSuccess: (result: unknown) => Promise;
+ onSuccess?: (result: unknown) => Promise;
}) => ({
isPending: false,
- mutate: () =>
- void options.onSuccess({
- telegramWebhook: { registered: false, error: 'network unavailable' },
- }),
+ mutate: () => {
+ if (options.onSuccess) {
+ void options.onSuccess({
+ telegramWebhook: { registered: false, error: 'network unavailable' },
+ });
+ }
+ },
}),
useQueryClient: () => ({ invalidateQueries: invalidateQueriesMock }),
}));
@@ -60,6 +63,9 @@ vi.mock('@/trpc/client', () => ({
linkedAccounts: {
telegram: { queryKey: () => ['linkedAccounts.telegram'] },
},
+ setupNew: {
+ trackCommsState: { mutationOptions: () => ({}) },
+ },
}),
}));
diff --git a/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx b/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx
index 1e03176b1..023594e78 100644
--- a/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepTelegramSetup.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@@ -37,6 +37,11 @@ export function StepTelegramSetup({
const [clearedSavedValues, setClearedSavedValues] = useState<
Record
>({});
+ const configuredTrackedRef = useRef(false);
+ const authedTrackedRef = useRef(false);
+ const trackCommsState = useMutation(
+ trpc.setupNew.trackCommsState.mutationOptions(),
+ );
const provider = useMemo(
() => status.data?.providers.find((item) => item.id === 'telegram') ?? null,
[status.data?.providers],
@@ -45,6 +50,11 @@ export function StepTelegramSetup({
const save = useMutation(
trpc.comms.saveAuthConfig.mutationOptions({
onSuccess: async (result) => {
+ setCredentialsSaved(true);
+ configuredTrackedRef.current = true;
+ trackCommsState.mutate({
+ provider: 'telegram',
+ });
await Promise.all([
queryClient.invalidateQueries({
queryKey: trpc.comms.status.queryKey(),
@@ -58,12 +68,31 @@ export function StepTelegramSetup({
`Telegram was saved, but Roomote could not connect the bot: ${result.telegramWebhook.error ?? 'unknown error'}`,
);
}
- setCredentialsSaved(true);
},
onError: (error) => toast.error(error.message),
}),
);
const isConfigured = credentialsSaved || provider?.setupSatisfied === true;
+ useEffect(() => {
+ if (
+ provider?.setupSatisfied === true &&
+ !credentialsSaved &&
+ !configuredTrackedRef.current
+ ) {
+ configuredTrackedRef.current = true;
+ trackCommsState.mutate({
+ provider: 'telegram',
+ });
+ }
+ }, [credentialsSaved, provider?.setupSatisfied, trackCommsState]);
+ useEffect(() => {
+ if (telegramAccount.data?.mapping && !authedTrackedRef.current) {
+ authedTrackedRef.current = true;
+ trackCommsState.mutate({
+ provider: 'telegram',
+ });
+ }
+ }, [telegramAccount.data?.mapping, trackCommsState]);
const isActionDisabled =
save.isPending ||
status.isLoading ||
diff --git a/apps/web/src/lib/server/setup-funnel-telemetry.test.ts b/apps/web/src/lib/server/setup-funnel-telemetry.test.ts
new file mode 100644
index 000000000..f0a5b94b0
--- /dev/null
+++ b/apps/web/src/lib/server/setup-funnel-telemetry.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ createEmptySetupNewState,
+ type SetupAuthStatus,
+ type SetupComputeStatus,
+ type SetupModelStatus,
+ type SetupSourceControlStatus,
+} from '@roomote/types';
+
+import {
+ evaluateSetupFunnelMilestones,
+ mergeSetupFunnelMilestones,
+} from './setup-funnel-telemetry';
+
+describe('setup funnel telemetry', () => {
+ it('records each deployment milestone only once', () => {
+ const result = mergeSetupFunnelMilestones(
+ {
+ authed: { at: '2026-08-01T00:00:00.000Z' },
+ },
+ [
+ { milestone: 'authed' },
+ {
+ milestone: 'comms_configured',
+ provider: 'slack',
+ preexisting: false,
+ },
+ {
+ milestone: 'comms_configured',
+ provider: 'microsoft',
+ preexisting: false,
+ },
+ ],
+ '2026-08-02T00:00:00.000Z',
+ );
+
+ expect(result.inserted).toEqual([
+ {
+ milestone: 'comms_configured',
+ provider: 'slack',
+ preexisting: false,
+ },
+ ]);
+ expect(result.milestones.comms_configured).toEqual({
+ at: '2026-08-02T00:00:00.000Z',
+ provider: 'slack',
+ preexisting: false,
+ });
+ });
+
+ it('derives achieved milestones from the setup status contracts', () => {
+ const setupNewState = {
+ ...createEmptySetupNewState(),
+ authProvider: 'slack' as const,
+ modelProvider: 'openai' as const,
+ sourceControlProvider: 'github' as const,
+ computeProvider: 'modal' as const,
+ };
+ const authSetup = {
+ selectedProvider: 'slack',
+ runtimeConfiguredProvider: null,
+ providers: [{ id: 'slack', setupSatisfied: true }],
+ } as unknown as SetupAuthStatus;
+ const modelSetup = {
+ setupSatisfied: true,
+ persistedProviderId: 'openai',
+ runtimeProviderId: null,
+ preselectedProvider: 'openai',
+ } as unknown as SetupModelStatus;
+ const sourceControlSetup = {
+ selectedProvider: 'github',
+ runtimeConfiguredProvider: null,
+ connectedProvider: 'github',
+ setupSatisfied: true,
+ providers: [
+ { provider: 'github', configStepSatisfied: true, connected: true },
+ ],
+ } as unknown as SetupSourceControlStatus;
+ const computeSetup = {
+ selectedProvider: 'modal',
+ runtimeDefaultProvider: null,
+ persistedDefaultProvider: 'modal',
+ providers: [{ provider: 'modal', configSatisfied: true }],
+ } as unknown as SetupComputeStatus;
+
+ expect(
+ evaluateSetupFunnelMilestones({
+ setupNewState,
+ hasSlack: true,
+ authSetup,
+ modelSetup,
+ sourceControlSetup,
+ computeSetup,
+ }),
+ ).toEqual([
+ { milestone: 'authed' },
+ {
+ milestone: 'comms_configured',
+ provider: 'slack',
+ preexisting: false,
+ },
+ {
+ milestone: 'comms_authed',
+ provider: 'slack',
+ preexisting: false,
+ },
+ {
+ milestone: 'inference_configured',
+ provider: 'openai',
+ preexisting: false,
+ },
+ {
+ milestone: 'source_control_configured',
+ provider: 'github',
+ preexisting: false,
+ },
+ {
+ milestone: 'source_control_authed',
+ provider: 'github',
+ preexisting: false,
+ },
+ {
+ milestone: 'sandbox_configured',
+ provider: 'modal',
+ preexisting: false,
+ },
+ ]);
+ });
+
+ it('tags provider state discovered before a wizard choice as preexisting', () => {
+ const setupNewState = createEmptySetupNewState();
+ const authSetup = {
+ selectedProvider: null,
+ runtimeConfiguredProvider: null,
+ preselectedProvider: 'microsoft',
+ providers: [{ id: 'microsoft', setupSatisfied: true }],
+ } as unknown as SetupAuthStatus;
+ const modelSetup = {
+ setupSatisfied: true,
+ persistedProviderId: null,
+ runtimeProviderId: 'anthropic',
+ preselectedProvider: 'anthropic',
+ } as unknown as SetupModelStatus;
+ const sourceControlSetup = {
+ selectedProvider: null,
+ runtimeConfiguredProvider: null,
+ connectedProvider: null,
+ preselectedProvider: 'gitlab',
+ setupSatisfied: false,
+ providers: [{ provider: 'gitlab', configStepSatisfied: true }],
+ } as unknown as SetupSourceControlStatus;
+ const computeSetup = {
+ selectedProvider: 'docker',
+ runtimeDefaultProvider: 'docker',
+ persistedDefaultProvider: null,
+ providers: [{ provider: 'docker', configSatisfied: true }],
+ } as unknown as SetupComputeStatus;
+
+ const milestones = evaluateSetupFunnelMilestones({
+ setupNewState,
+ hasSlack: false,
+ authSetup,
+ modelSetup,
+ sourceControlSetup,
+ computeSetup,
+ });
+
+ expect(milestones.slice(1)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ milestone: 'comms_configured',
+ provider: 'microsoft',
+ preexisting: true,
+ }),
+ expect.objectContaining({
+ milestone: 'source_control_configured',
+ provider: 'gitlab',
+ preexisting: true,
+ }),
+ expect.objectContaining({
+ milestone: 'inference_configured',
+ preexisting: true,
+ }),
+ expect.objectContaining({
+ milestone: 'sandbox_configured',
+ preexisting: true,
+ }),
+ ]),
+ );
+ });
+});
diff --git a/apps/web/src/lib/server/setup-funnel-telemetry.ts b/apps/web/src/lib/server/setup-funnel-telemetry.ts
new file mode 100644
index 000000000..c2aa41fc2
--- /dev/null
+++ b/apps/web/src/lib/server/setup-funnel-telemetry.ts
@@ -0,0 +1,227 @@
+import { db, deploymentSettings, eq, sql } from '@roomote/db/server';
+import {
+ captureActivationSetupMilestone,
+ isAnonymousAnalyticsEnabled,
+} from '@roomote/telemetry/server';
+import type {
+ ActivationSetupMilestone,
+ ActivationSetupMilestoneProperties,
+} from '@roomote/telemetry';
+import type {
+ SetupAuthStatus,
+ SetupComputeStatus,
+ SetupModelStatus,
+ SetupNewState,
+ SetupSourceControlStatus,
+} from '@roomote/types';
+
+const METADATA_KEY = 'setup_funnel_milestones';
+
+type SetupFunnelMilestoneInput = ActivationSetupMilestoneProperties & {
+ milestone: ActivationSetupMilestone;
+};
+
+type RecordedSetupFunnelMilestone = ActivationSetupMilestoneProperties & {
+ at: string;
+};
+
+type RecordedSetupFunnelMilestones = Partial<
+ Record
+>;
+
+function normalizeMetadata(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+function normalizeRecordedMilestones(
+ value: unknown,
+): RecordedSetupFunnelMilestones {
+ return normalizeMetadata(value) as RecordedSetupFunnelMilestones;
+}
+
+export function mergeSetupFunnelMilestones(
+ current: RecordedSetupFunnelMilestones,
+ candidates: SetupFunnelMilestoneInput[],
+ recordedAt: string,
+): {
+ milestones: RecordedSetupFunnelMilestones;
+ inserted: SetupFunnelMilestoneInput[];
+} {
+ const milestones = { ...current };
+ const inserted: SetupFunnelMilestoneInput[] = [];
+
+ for (const candidate of candidates) {
+ if (milestones[candidate.milestone]) {
+ continue;
+ }
+
+ milestones[candidate.milestone] = {
+ at: recordedAt,
+ ...(candidate.provider === undefined
+ ? {}
+ : { provider: candidate.provider }),
+ ...(candidate.preexisting === undefined
+ ? {}
+ : { preexisting: candidate.preexisting }),
+ };
+ inserted.push(candidate);
+ }
+
+ return { milestones, inserted };
+}
+
+export function evaluateSetupFunnelMilestones(input: {
+ setupNewState: SetupNewState;
+ hasSlack: boolean;
+ authSetup: SetupAuthStatus;
+ modelSetup: SetupModelStatus;
+ computeSetup: SetupComputeStatus;
+ sourceControlSetup: SetupSourceControlStatus;
+}): SetupFunnelMilestoneInput[] {
+ const candidates: SetupFunnelMilestoneInput[] = [{ milestone: 'authed' }];
+ const authProvider =
+ input.setupNewState.authProvider ??
+ input.authSetup.runtimeConfiguredProvider ??
+ input.authSetup.selectedProvider ??
+ input.authSetup.preselectedProvider;
+ const authProviderStatus = input.authSetup.providers.find(
+ (provider) => provider.id === authProvider,
+ );
+ const authPreexisting = input.setupNewState.authProvider === null;
+
+ if (authProvider && authProviderStatus?.setupSatisfied) {
+ candidates.push({
+ milestone: 'comms_configured',
+ provider: authProvider,
+ preexisting: authPreexisting,
+ });
+ }
+ if (authProvider === 'slack' && input.hasSlack) {
+ candidates.push({
+ milestone: 'comms_authed',
+ provider: authProvider,
+ preexisting: authPreexisting,
+ });
+ }
+
+ const modelProvider =
+ input.setupNewState.modelProvider ??
+ input.modelSetup.persistedProviderId ??
+ input.modelSetup.runtimeProviderId ??
+ input.modelSetup.preselectedProvider;
+ if (input.modelSetup.setupSatisfied) {
+ candidates.push({
+ milestone: 'inference_configured',
+ provider: modelProvider,
+ preexisting: input.setupNewState.modelProvider === null,
+ });
+ }
+
+ const sourceControlProvider =
+ input.setupNewState.sourceControlProvider ??
+ input.sourceControlSetup.runtimeConfiguredProvider ??
+ input.sourceControlSetup.connectedProvider ??
+ input.sourceControlSetup.selectedProvider ??
+ input.sourceControlSetup.preselectedProvider;
+ const sourceControlProviderStatus = input.sourceControlSetup.providers.find(
+ (provider) => provider.provider === sourceControlProvider,
+ );
+ const sourceControlPreexisting =
+ input.setupNewState.sourceControlProvider === null;
+ if (
+ sourceControlProvider &&
+ sourceControlProviderStatus?.configStepSatisfied
+ ) {
+ candidates.push({
+ milestone: 'source_control_configured',
+ provider: sourceControlProvider,
+ preexisting: sourceControlPreexisting,
+ });
+ }
+ if (sourceControlProvider && input.sourceControlSetup.setupSatisfied) {
+ candidates.push({
+ milestone: 'source_control_authed',
+ provider: sourceControlProvider,
+ preexisting: sourceControlPreexisting,
+ });
+ }
+
+ const computeProvider =
+ input.setupNewState.computeProvider ??
+ input.computeSetup.selectedProvider ??
+ input.computeSetup.runtimeDefaultProvider ??
+ input.computeSetup.persistedDefaultProvider;
+ const computeProviderStatus = input.computeSetup.providers.find(
+ (provider) => provider.provider === computeProvider,
+ );
+ if (computeProvider && computeProviderStatus?.configSatisfied) {
+ candidates.push({
+ milestone: 'sandbox_configured',
+ provider: computeProvider,
+ preexisting: input.setupNewState.computeProvider === null,
+ });
+ }
+
+ return candidates;
+}
+
+export async function recordSetupFunnelMilestones(
+ candidates: SetupFunnelMilestoneInput[],
+): Promise {
+ if (candidates.length === 0 || !(await isAnonymousAnalyticsEnabled())) {
+ return;
+ }
+
+ try {
+ const inserted = await db.transaction(async (tx) => {
+ await tx.execute(
+ sql`SELECT pg_advisory_xact_lock(hashtext('setup-funnel-milestones'))`,
+ );
+ await tx
+ .insert(deploymentSettings)
+ .values({ id: 'default' })
+ .onConflictDoNothing();
+
+ const settings = await tx.query.deploymentSettings.findFirst({
+ where: eq(deploymentSettings.id, 'default'),
+ columns: { metadata: true, setupCompletedAt: true },
+ });
+ if (!settings || settings.setupCompletedAt !== null) {
+ return [];
+ }
+
+ const metadata = normalizeMetadata(settings.metadata);
+ const result = mergeSetupFunnelMilestones(
+ normalizeRecordedMilestones(metadata[METADATA_KEY]),
+ candidates,
+ new Date().toISOString(),
+ );
+ if (result.inserted.length === 0) {
+ return [];
+ }
+
+ await tx
+ .update(deploymentSettings)
+ .set({
+ metadata: sql`jsonb_set(coalesce(${deploymentSettings.metadata}, '{}'::jsonb), ARRAY[${METADATA_KEY}]::text[], ${JSON.stringify(result.milestones)}::jsonb, true)`,
+ })
+ .where(eq(deploymentSettings.id, 'default'));
+
+ return result.inserted;
+ });
+
+ for (const { milestone, provider, preexisting } of inserted) {
+ await captureActivationSetupMilestone(milestone, {
+ ...(provider === undefined ? {} : { provider }),
+ ...(preexisting === undefined ? {} : { preexisting }),
+ });
+ }
+ } catch (error) {
+ console.warn(
+ '[setup-funnel-telemetry] failed to record milestones:',
+ error instanceof Error ? error.message : error,
+ );
+ }
+}
diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts
index 1ac0123b6..bd0e22c15 100644
--- a/apps/web/src/trpc/commands/setup-new/index.test.ts
+++ b/apps/web/src/trpc/commands/setup-new/index.test.ts
@@ -14,6 +14,11 @@ const {
mockResolveSavedWorkerImage,
mockResolveGiteaBaseUrl,
mockResolveDeploymentEnvVar,
+ mockRecordSetupFunnelMilestones,
+ mockGetLinkedTelegramAccount,
+ mockGetLinkedDiscordAccount,
+ mockGetTeamsIntegrationStatus,
+ mockInvalidateTelegramRuntimeCredentialsCache,
} = vi.hoisted(() => ({
mockValidateTeamsBotCredentials: vi.fn(async () => undefined),
mockTxSelect: vi.fn(),
@@ -33,6 +38,25 @@ const {
.fn()
.mockResolvedValue('https://gitea.example.com'),
mockResolveDeploymentEnvVar: vi.fn().mockResolvedValue(null),
+ mockRecordSetupFunnelMilestones: vi.fn().mockResolvedValue(undefined),
+ mockGetLinkedTelegramAccount: vi.fn(),
+ mockGetLinkedDiscordAccount: vi.fn(),
+ mockGetTeamsIntegrationStatus: vi.fn(),
+ mockInvalidateTelegramRuntimeCredentialsCache: vi.fn(),
+}));
+
+vi.mock('@/lib/server/setup-funnel-telemetry', () => ({
+ evaluateSetupFunnelMilestones: vi.fn(() => []),
+ recordSetupFunnelMilestones: mockRecordSetupFunnelMilestones,
+}));
+
+vi.mock('../linked-accounts', () => ({
+ getLinkedTelegramAccountCommand: mockGetLinkedTelegramAccount,
+ getLinkedDiscordAccountCommand: mockGetLinkedDiscordAccount,
+}));
+
+vi.mock('../teams', () => ({
+ getTeamsIntegrationStatusCommand: mockGetTeamsIntegrationStatus,
}));
vi.mock('../compute/compute-provisioning', async (importOriginal) => {
@@ -123,6 +147,8 @@ vi.mock('@roomote/db/server', () => ({
identityErrorCode: null,
})),
invalidateTeamsBotRuntimeCredentialsCache: vi.fn(),
+ invalidateTelegramRuntimeCredentialsCache:
+ mockInvalidateTelegramRuntimeCredentialsCache,
slackInstallations: {},
slackUserMappings: {},
sql: vi.fn(),
@@ -226,6 +252,9 @@ import {
saveSetupNewSourceControlConfigCommand,
saveSetupNewSourceControlProviderChoiceCommand,
startSetupNewOnboardingTaskCommand,
+ trackSetupBootstrapWelcomeSeenCommand,
+ trackSetupCommsStateCommand,
+ trackSetupWelcomeSeenCommand,
} from './index';
import {
TaskPayloadKind,
@@ -440,6 +469,64 @@ describe('setup-new auth config commands', () => {
});
});
+describe('setup funnel milestone commands', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSetupTokenState.requiredToken = undefined;
+ mockSetupTokenState.inviteCookieToken = null;
+ mockGetLinkedTelegramAccount.mockResolvedValue({
+ configured: false,
+ mapping: null,
+ });
+ mockGetLinkedDiscordAccount.mockResolvedValue({
+ configured: false,
+ mapping: null,
+ });
+ });
+
+ it('records welcome after an admin reaches signed-in setup', async () => {
+ await trackSetupWelcomeSeenCommand(buildMockAuth());
+
+ expect(mockRecordSetupFunnelMilestones).toHaveBeenCalledWith([
+ { milestone: 'welcome' },
+ ]);
+ });
+
+ it('requires a valid bootstrap token before recording welcome', async () => {
+ mockSetupTokenState.requiredToken = 'expected-token';
+
+ await expect(
+ trackSetupBootstrapWelcomeSeenCommand({ setupToken: 'wrong-token' }),
+ ).rejects.toThrow('A valid setup token is required.');
+ expect(mockRecordSetupFunnelMilestones).not.toHaveBeenCalled();
+ });
+
+ it('derives communications milestones from authoritative account state', async () => {
+ mockGetLinkedTelegramAccount.mockResolvedValue({
+ configured: true,
+ mapping: { telegramUserId: '42' },
+ });
+
+ await trackSetupCommsStateCommand(buildMockAuth(), {
+ provider: 'telegram',
+ });
+
+ expect(mockRecordSetupFunnelMilestones).toHaveBeenCalledWith([
+ {
+ milestone: 'comms_configured',
+ provider: 'telegram',
+ },
+ {
+ milestone: 'comms_authed',
+ provider: 'telegram',
+ },
+ ]);
+ expect(
+ mockInvalidateTelegramRuntimeCredentialsCache,
+ ).toHaveBeenCalledOnce();
+ });
+});
+
describe('setup bootstrap token gating', () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts
index 120e0488d..903e9235a 100644
--- a/apps/web/src/trpc/commands/setup-new/index.ts
+++ b/apps/web/src/trpc/commands/setup-new/index.ts
@@ -34,6 +34,7 @@ import {
resolveDeploymentEnvVar,
purgeSavedDeploymentWorkerImage,
resolveTelegramRuntimeCredentials,
+ invalidateTelegramRuntimeCredentialsCache,
resolveDiscordRuntimeCredentials,
invalidateTeamsBotRuntimeCredentialsCache,
isChatGptSubscriptionConnected,
@@ -109,6 +110,10 @@ import {
} from '@roomote/types';
import type { UserAuthSuccess } from '@/types';
+import {
+ evaluateSetupFunnelMilestones,
+ recordSetupFunnelMilestones,
+} from '@/lib/server/setup-funnel-telemetry';
import {
assertSetupTokenValid,
getLatestTaskRunsByTaskId,
@@ -130,6 +135,11 @@ import {
normalizeRepositorySelection,
} from '@/lib/setup-new';
import type { QueuedOnboardingTask } from './types';
+import {
+ getLinkedDiscordAccountCommand,
+ getLinkedTelegramAccountCommand,
+} from '../linked-accounts';
+import { getTeamsIntegrationStatusCommand } from '../teams';
import {
assertAdmin,
@@ -1448,7 +1458,7 @@ export async function getSetupNewStatusCommand(auth: UserAuthSuccess) {
gitlabBaseUrl,
});
- return {
+ const status = {
hasGitHub: baseStatus.hasGitHub,
hasSlack: slackAccessStatus.hasSlackUserMapping,
hasSlackInstallation: slackAccessStatus.hasSlackInstallation,
@@ -1468,6 +1478,12 @@ export async function getSetupNewStatusCommand(auth: UserAuthSuccess) {
computeSetup,
sourceControlSetup,
};
+
+ if (baseStatus.setupCompletedAt === null) {
+ await recordSetupFunnelMilestones(evaluateSetupFunnelMilestones(status));
+ }
+
+ return status;
}
export async function saveSetupNewModelConfigCommand(
@@ -2340,6 +2356,66 @@ export async function getSetupBootstrapStatusCommand(input?: {
};
}
+export async function trackSetupWelcomeSeenCommand(auth: UserAuthSuccess) {
+ assertAdmin(auth);
+ await recordSetupFunnelMilestones([{ milestone: 'welcome' }]);
+}
+
+export async function trackSetupCommsStateCommand(
+ auth: UserAuthSuccess,
+ input: {
+ provider: 'microsoft' | 'telegram' | 'discord';
+ },
+) {
+ assertAdmin(auth);
+ const candidates = [];
+
+ if (input.provider === 'microsoft') {
+ const status = await getTeamsIntegrationStatusCommand(auth);
+ if (status.botConfigured && status.microsoftAuthConfigured) {
+ candidates.push({
+ milestone: 'comms_configured' as const,
+ provider: input.provider,
+ });
+ }
+ if (status.primaryConversationReady) {
+ candidates.push({
+ milestone: 'comms_authed' as const,
+ provider: input.provider,
+ });
+ }
+ } else {
+ if (input.provider === 'telegram') {
+ invalidateTelegramRuntimeCredentialsCache();
+ }
+ const status =
+ input.provider === 'telegram'
+ ? await getLinkedTelegramAccountCommand(auth)
+ : await getLinkedDiscordAccountCommand(auth);
+ if (status.configured) {
+ candidates.push({
+ milestone: 'comms_configured' as const,
+ provider: input.provider,
+ });
+ }
+ if (status.mapping) {
+ candidates.push({
+ milestone: 'comms_authed' as const,
+ provider: input.provider,
+ });
+ }
+ }
+
+ await recordSetupFunnelMilestones(candidates);
+}
+
+export async function trackSetupBootstrapWelcomeSeenCommand(input?: {
+ setupToken?: string;
+}) {
+ assertSetupTokenValid(await resolveSetupTokenInput(input?.setupToken));
+ await recordSetupFunnelMilestones([{ milestone: 'welcome' }]);
+}
+
export async function saveSetupBootstrapAuthProviderChoiceCommand(input: {
provider: SetupAuthProviderId;
setupToken?: string;
diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts
index c996e37b8..f47dd2914 100644
--- a/apps/web/src/trpc/routers/_app.ts
+++ b/apps/web/src/trpc/routers/_app.ts
@@ -252,6 +252,9 @@ import {
cancelSetupNewOnboardingTaskCommand,
resetSetupNewSelectionCommand,
ensureSetupNewDefaultAgentsCommand,
+ trackSetupBootstrapWelcomeSeenCommand,
+ trackSetupCommsStateCommand,
+ trackSetupWelcomeSeenCommand,
} from '../commands/setup-new';
import {
getOnboardingStatusCommand,
@@ -2251,6 +2254,20 @@ export const appRouter = createRouter({
getSetupNewStatusCommand(auth),
),
+ trackWelcomeSeen: protectedProcedure.mutation(({ ctx: { auth } }) =>
+ trackSetupWelcomeSeenCommand(auth),
+ ),
+
+ trackCommsState: protectedProcedure
+ .input(
+ z.object({
+ provider: z.enum(['microsoft', 'telegram', 'discord']),
+ }),
+ )
+ .mutation(({ ctx: { auth }, input }) =>
+ trackSetupCommsStateCommand(auth, input),
+ ),
+
saveAuthProviderChoice: protectedProcedure
.input(
z.object({
@@ -2389,6 +2406,16 @@ export const appRouter = createRouter({
)
.query(({ input }) => getSetupBootstrapStatusCommand(input)),
+ trackWelcomeSeen: publicProcedure
+ .input(
+ z
+ .object({
+ setupToken: z.string().optional(),
+ })
+ .optional(),
+ )
+ .mutation(({ input }) => trackSetupBootstrapWelcomeSeenCommand(input)),
+
saveAuthProviderChoice: publicProcedure
.input(
z.object({
diff --git a/packages/telemetry/src/__tests__/telemetry.test.ts b/packages/telemetry/src/__tests__/telemetry.test.ts
index 1026e2299..c858c29dc 100644
--- a/packages/telemetry/src/__tests__/telemetry.test.ts
+++ b/packages/telemetry/src/__tests__/telemetry.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
buildActivationPrMergedProperties,
+ buildActivationSetupMilestoneProperties,
buildActivationTaskProperties,
PAGEVIEW_EVENT,
TELEMETRY_EVENT_NAME_PATTERN,
@@ -186,6 +187,16 @@ describe('TELEMETRY_EVENT_NAME_PATTERN', () => {
});
describe('activation event properties', () => {
+ it('allows only provider classifications on setup milestones', () => {
+ expect(
+ buildActivationSetupMilestoneProperties({
+ provider: 'slack',
+ preexisting: true,
+ }),
+ ).toEqual({ provider: 'slack', preexisting: true });
+ expect(buildActivationSetupMilestoneProperties({})).toBeUndefined();
+ });
+
it('allows only safe task routing classifications', () => {
expect(
buildActivationTaskProperties({
diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts
index ae82ebd40..5afea2d51 100644
--- a/packages/telemetry/src/index.ts
+++ b/packages/telemetry/src/index.ts
@@ -44,6 +44,45 @@ export const ACTIVATION_ENVIRONMENT_SOURCES = [
export type ActivationEnvironmentSource =
(typeof ACTIVATION_ENVIRONMENT_SOURCES)[number];
+export const ACTIVATION_SETUP_MILESTONES = [
+ 'welcome',
+ 'authed',
+ 'comms_configured',
+ 'comms_authed',
+ 'source_control_configured',
+ 'source_control_authed',
+ 'inference_configured',
+ 'sandbox_configured',
+] as const;
+
+export type ActivationSetupMilestone =
+ (typeof ACTIVATION_SETUP_MILESTONES)[number];
+
+export type ActivationSetupMilestoneProperties = {
+ provider?: string;
+ preexisting?: boolean;
+};
+
+export function buildActivationSetupMilestoneProperties(
+ properties: ActivationSetupMilestoneProperties,
+): TelemetryEventProperties | undefined {
+ if (
+ properties.provider === undefined &&
+ properties.preexisting === undefined
+ ) {
+ return undefined;
+ }
+
+ return {
+ ...(properties.provider === undefined
+ ? {}
+ : { provider: properties.provider }),
+ ...(properties.preexisting === undefined
+ ? {}
+ : { preexisting: properties.preexisting }),
+ };
+}
+
export type ActivationTaskProperties = {
workflow: string;
surface: string;
diff --git a/packages/telemetry/src/server/index.ts b/packages/telemetry/src/server/index.ts
index f5951a1c4..b33cfeeac 100644
--- a/packages/telemetry/src/server/index.ts
+++ b/packages/telemetry/src/server/index.ts
@@ -30,8 +30,11 @@ import {
type PingVersionCheckResponse,
type TelemetryEventProperties,
type ActivationEnvironmentSource,
+ type ActivationSetupMilestone,
+ type ActivationSetupMilestoneProperties,
type ActivationTaskProperties,
buildActivationPrMergedProperties,
+ buildActivationSetupMilestoneProperties,
buildActivationTaskProperties,
} from '../index';
@@ -319,6 +322,16 @@ export async function captureActivationEnvironmentSaved(
return captureInstanceEvent('activation_environment_saved', { source });
}
+export async function captureActivationSetupMilestone(
+ milestone: ActivationSetupMilestone,
+ properties: ActivationSetupMilestoneProperties = {},
+): Promise {
+ return captureInstanceEvent(
+ `activation_setup_${milestone}`,
+ buildActivationSetupMilestoneProperties(properties),
+ );
+}
+
export async function captureActivationTaskCreated(
properties: ActivationTaskProperties,
): Promise {
From 160fadac9f5fe5afdf32af08128d773cd0a4e2cb Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 13:54:42 +0100
Subject: [PATCH 07/13] [Improve] Guide users to automations and recipes
(#1086)
* feat: promote automation onboarding and recipes
* ui
* test: fix CI navigation and database isolation failures
---------
Co-authored-by: Roomote
Co-authored-by: Bruno Bergher
---
.../home/OnboardingCard.client.test.tsx | 25 ++++++++++-
.../(authenticated)/home/OnboardingCard.tsx | 32 +++++++-------
.../navbar/NavbarDrawer.client.test.tsx | 2 +-
.../layout/navigation-items.test.ts | 4 +-
.../src/components/layout/navigation-items.ts | 16 +++----
.../layout/side-nav/SideNav.client.test.tsx | 4 +-
.../settings/DeploymentTimeZoneSetting.tsx | 34 +++++++-------
.../automations/AutomationsSettings.tsx | 9 +++-
.../automations/CustomAutomationsSection.tsx | 6 +--
.../pages/AutomationsSettingsPage.tsx | 44 +++++++++++++++----
apps/web/src/lib/docs.ts | 5 +++
.../__tests__/settings-update-discord.test.ts | 4 +-
.../merged-pr-audit-runner.pagination.test.ts | 6 ++-
13 files changed, 126 insertions(+), 65 deletions(-)
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/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/DeploymentTimeZoneSetting.tsx b/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx
index 6f39e6445..498211ab3 100644
--- a/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx
+++ b/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx
@@ -22,6 +22,7 @@ import {
PopoverContent,
PopoverTrigger,
Skeleton,
+ Sun,
} from '@/components/system';
const FALLBACK_TIME_ZONES = [
@@ -90,21 +91,24 @@ export function DeploymentTimeZoneSetting() {
if (!isEditing) {
return (
-
- Scheduling timezone:{' '}
-
- {formatTimeZone(effectiveTimeZone)}
- {' '}
- setIsEditing(true)}
- >
- Edit
-
-
+
+
+
+ Scheduling timezone:{' '}
+
+ {formatTimeZone(effectiveTimeZone)}
+ {' '}
+ setIsEditing(true)}
+ >
+ Edit
+
+
+
);
}
diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx
index c80995dc1..e529c4e6a 100644
--- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx
+++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx
@@ -1355,7 +1355,12 @@ function AutomationCard({
className={cn('scroll-mt-24', iconEnabled ? 'order-[-20]' : 'order-0')}
aria-disabled={disabled || undefined}
>
-
+
@@ -2441,7 +2446,7 @@ export function AutomationsSettings() {
]),
) as Record
;
return (
-
+
{!settingsQuery.isPending &&
capabilities?.requiresSlackReconnect &&
capabilities.missingScopes.length > 0 ? (
diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
index 56372e56b..ed750eb55 100644
--- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
+++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
@@ -894,10 +894,6 @@ export function CustomAutomationsSection() {
>
Custom
-
- Create your own scheduled agent runs with a prompt, frequency,
- environment, and optional report channel.
-
{!isCreating && !editingId ? (
) : rows.length === 0 && !isCreating ? (
-
+
No custom automations created yet.
) : (
diff --git a/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx b/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx
index 67a82004c..0f28ae353 100644
--- a/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx
+++ b/apps/web/src/components/settings/pages/AutomationsSettingsPage.tsx
@@ -3,23 +3,49 @@
import { AutomationsSettings } from '@/components/settings/automations';
import { DeploymentTimeZoneSetting } from '@/components/settings/DeploymentTimeZoneSetting';
import { useAuthorizedUser } from '@/hooks/useUser';
+import { DOCS_COOKBOOK_URL } from '@/lib/docs';
import { PRODUCT_NAME } from '@roomote/types';
-import { Alert, AlertCircle, AlertDescription } from '@/components/system';
+import {
+ Alert,
+ AlertCircle,
+ AlertDescription,
+ Button,
+ Lightbulb,
+} from '@/components/system';
+import { BookOpenText } from 'lucide-react';
export function AutomationsSettingsPage() {
const { isAdmin } = useAuthorizedUser();
return (
-
-
-
- Automations
-
-
- Get {PRODUCT_NAME} automatically working on your behalf.
-
+
+
{isAdmin ? (
diff --git a/apps/web/src/lib/docs.ts b/apps/web/src/lib/docs.ts
index 50b2ba5aa..32a969c8e 100644
--- a/apps/web/src/lib/docs.ts
+++ b/apps/web/src/lib/docs.ts
@@ -4,6 +4,11 @@
*/
export const DOCS_BASE_URL = 'https://docs.roomote.dev';
+/**
+ * Cookbook recipes for common Roomote workflows.
+ */
+export const DOCS_COOKBOOK_URL = `${DOCS_BASE_URL}/cookbook`;
+
/**
* Public docs page describing the environment definition YAML format.
*/
diff --git a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts
index ee0382dfe..6f4430c23 100644
--- a/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts
+++ b/apps/web/src/trpc/commands/automations/__tests__/settings-update-discord.test.ts
@@ -199,7 +199,7 @@ describe('updateBackgroundAgentSettingsCommand Discord destinations', () => {
await db.delete(deploymentSettings);
await db.delete(discordInstallations);
await db.delete(slackInstallations);
- await db.delete(users);
+ await db.delete(users).where(eq(users.id, adminAuth.userId));
});
it('preserves a disabled emoji trigger during an unrelated save', async () => {
@@ -776,7 +776,7 @@ describe('updateBackgroundAgentSettingsCommand Discord channel auto-start', () =
await db.delete(deploymentSettings);
await db.delete(discordInstallations);
await db.delete(slackInstallations);
- await db.delete(users);
+ await db.delete(users).where(eq(users.id, adminAuth.userId));
});
it('writes discord auto-respond targets alongside Slack ones with merged order', async () => {
diff --git a/packages/sdk/src/server/automations/__tests__/merged-pr-audit-runner.pagination.test.ts b/packages/sdk/src/server/automations/__tests__/merged-pr-audit-runner.pagination.test.ts
index b39fef5e9..3def0ed75 100644
--- a/packages/sdk/src/server/automations/__tests__/merged-pr-audit-runner.pagination.test.ts
+++ b/packages/sdk/src/server/automations/__tests__/merged-pr-audit-runner.pagination.test.ts
@@ -380,7 +380,9 @@ describe('getMergedPullRequests', () => {
provider: 'bitbucket',
});
- const mergedAt = new Date('2026-07-10T00:00:00Z');
+ // Keep this legacy-cursor fixture outside the historical windows used by
+ // the other real-database suites, which run concurrently in Vitest.
+ const mergedAt = new Date('2099-07-10T00:00:00Z');
await insertMergedFacts([
{
repositoryId: repoA.id,
@@ -406,7 +408,7 @@ describe('getMergedPullRequests', () => {
cursor: { mergedAt: mergedAt.toISOString(), externalPullRequestId: 5 },
cursorDate: mergedAt,
},
- new Date('2026-07-11T00:00:00Z'),
+ new Date('2099-07-11T00:00:00Z'),
);
expect(manifestKeys(batch.pullRequests).sort()).toEqual([
From 7c879dac965bb77867fc5e0574af9d1929f291f0 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:13:28 +0100
Subject: [PATCH 08/13] [Feat] Add Resend MCP integration with safer tool
defaults (#939)
* feat: add Resend MCP integration
* fix: disable unsafe Resend automation tools
* fix: show Resend safety defaults in catalog
* fix: close Resend permission escape paths
* fix: close remaining Resend tool escape paths
* fix: configure Resend OAuth endpoints
* fix: make Resend tool schemas Azure-compatible
* test: update Resend integration description assertion
---------
Co-authored-by: Roomote
Co-authored-by: Bruno Bergher
---
.../mcp/__tests__/integration-mcp.test.ts | 75 +++++++++++++++++++
apps/api/src/handlers/mcp/integration-mcp.ts | 3 +
apps/api/src/handlers/mcp/proxy-utils.ts | 49 ++++++++++--
apps/docs/docs.json | 1 +
apps/docs/integrations/index.mdx | 1 +
apps/docs/integrations/resend.mdx | 45 +++++++++++
.../callback/__tests__/route.test.ts | 55 +++++++++++++-
.../src/app/api/mcp-oauth/callback/route.ts | 8 ++
.../[connectionId]/__tests__/route.test.ts | 39 ++++++++++
.../initiate/[connectionId]/route.ts | 10 ++-
.../components/settings/Integrations.test.tsx | 9 +++
.../src/components/settings/Integrations.tsx | 2 +
.../system/custom/logos/brand-icon.tsx | 2 +
.../trpc/commands/mcp-connections/index.ts | 7 ++
.../__tests__/integration-setup.test.ts | 10 +++
.../roomote-mcp-server/integration-setup.ts | 8 ++
.../src/server/mcp-self-setup/catalog.ts | 7 ++
.../slack-mcp-setup-matching.test.ts | 4 +
packages/slack/src/mcp-recommendations.ts | 2 +
.../types/src/__tests__/mcp-oauth.test.ts | 56 ++++++++++++++
packages/types/src/mcp-oauth.ts | 64 ++++++++++++++++
packages/types/src/mcp-service-detection.ts | 23 ++++++
22 files changed, 467 insertions(+), 13 deletions(-)
create mode 100644 apps/docs/integrations/resend.mdx
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/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/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/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx
index 9d2ce8baf..47f09c9b7 100644
--- a/apps/web/src/components/settings/Integrations.test.tsx
+++ b/apps/web/src/components/settings/Integrations.test.tsx
@@ -808,6 +808,7 @@ describe('Integrations settings', () => {
'PostHog',
'Pylon',
'Railway',
+ 'Resend',
'Sentry',
'Snowflake',
'Supabase',
@@ -840,6 +841,14 @@ describe('Integrations settings', () => {
expect(
screen.getByRole('button', { name: 'Connect and enable Railway' }),
).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Connect and enable Resend' }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Inspect and manage shared email infrastructure through Resend from Roomote tasks.',
+ ),
+ ).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Connect and enable Jira' }),
).toBeInTheDocument();
diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx
index d1850f2c5..afc51d9f9 100644
--- a/apps/web/src/components/settings/Integrations.tsx
+++ b/apps/web/src/components/settings/Integrations.tsx
@@ -104,6 +104,8 @@ const DEEP_LINK_ENABLE_DESCRIPTIONS: Record = {
'Roomote will be able to inspect analytics, feature flags, and experiments.',
railway:
'Roomote will be able to inspect Railway account, project, and service inventory.',
+ resend:
+ 'Roomote will be able to inspect and manage shared email infrastructure. Sending, credential creation, automation triggers, and contact mutations start disabled.',
sentry:
'Roomote will be able to inspect Sentry issue context and run scheduled Sentry triage through MCP.',
supabase: 'Roomote will get read-only database access and platform context.',
diff --git a/apps/web/src/components/system/custom/logos/brand-icon.tsx b/apps/web/src/components/system/custom/logos/brand-icon.tsx
index 0e6458265..6ef64f4ad 100644
--- a/apps/web/src/components/system/custom/logos/brand-icon.tsx
+++ b/apps/web/src/components/system/custom/logos/brand-icon.tsx
@@ -21,6 +21,7 @@ import {
siPagerduty,
siPosthog,
siRailway,
+ siResend,
siSentry,
siSnowflake,
siSupabase,
@@ -56,6 +57,7 @@ const SIMPLE_ICONS: Record = {
pagerduty: siPagerduty,
posthog: siPosthog,
railway: siRailway,
+ resend: siResend,
snowflake: siSnowflake,
supabase: siSupabase,
telegram: siTelegram,
diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts
index 5d12237bf..ea9cf2941 100644
--- a/apps/web/src/trpc/commands/mcp-connections/index.ts
+++ b/apps/web/src/trpc/commands/mcp-connections/index.ts
@@ -14,6 +14,7 @@ import {
getAllowedIntegrationMcpToolNames,
getMcpIntegration,
getMcpIntegrationConnectionScope,
+ getMcpIntegrationDefaultDisabledTools,
type McpConnectionRole,
isMcpConnectionAsanaConfig,
isMcpConnectionGrafanaConfig,
@@ -605,12 +606,18 @@ export async function setDeploymentMcpEnabledCommand(
await assertStaticOauthReady(integration);
}
+ const defaultDisabledTools =
+ getMcpIntegrationDefaultDisabledTools(integration);
+
const [result] = await db
.insert(deploymentMcpEnablements)
.values({
mcpId: input.mcpId,
enabled: input.enabled,
enabledByUserId: auth.userId,
+ ...(defaultDisabledTools.length > 0
+ ? { disabledTools: [...defaultDisabledTools] }
+ : {}),
})
.onConflictDoUpdate({
target: [deploymentMcpEnablements.mcpId],
diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts
index 828b8c1be..5e77c2262 100644
--- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts
+++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts
@@ -83,4 +83,14 @@ describe('integration setup guide', () => {
'Only after Zero is enabled for the deployment do I install the zero CLI',
);
});
+
+ it('documents Resend tool safety defaults', () => {
+ expect(INTEGRATION_SETUP_CONTENT).toContain('# Resend');
+ expect(INTEGRATION_SETUP_CONTENT).toContain(
+ 'That admin connects Resend once for the workspace via OAuth.',
+ );
+ expect(INTEGRATION_SETUP_CONTENT).toContain(
+ 'Email sending, credential creation, scheduled-send changes, automation mutations and triggers, and contact mutations are disabled',
+ );
+ });
});
diff --git a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts
index f2533fee3..42e5a55d4 100644
--- a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts
+++ b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts
@@ -95,6 +95,14 @@ Railway uses OAuth:
Once connected, I can confirm the connected Railway account and list Railway projects and services during tasks.
+# Resend
+
+Resend uses OAuth:
+1. An admin enables Resend from Settings > Integrations.
+2. That admin connects Resend once for the workspace via OAuth.
+
+Once connected, I can inspect email delivery, received messages, domains, contacts, templates, broadcasts, and related infrastructure. Email sending, credential creation, scheduled-send changes, automation mutations and triggers, and contact mutations are disabled until an admin enables those tools from Manage tools.
+
# Braintrust
Braintrust uses OAuth:
diff --git a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts
index efe1c5d8c..0dc0b6245 100644
--- a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts
+++ b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts
@@ -137,6 +137,13 @@ export const MCP_SETUP_INTEGRATION_METADATA: Record<
'Inspect the services inside a Railway project',
],
},
+ resend: {
+ capabilities: [
+ 'Inspect sent and received email delivery details',
+ 'Review domains, logs, templates, contacts, and broadcasts',
+ 'Opt in to email sending, credential creation, automation triggers, and contact mutations when needed',
+ ],
+ },
vercel: {
capabilities: [
'Inspect Vercel teams and projects',
diff --git a/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts b/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts
index 71661af72..a3f67f262 100644
--- a/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts
+++ b/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts
@@ -63,6 +63,8 @@ describe('matchSlackMcpSetupService', () => {
['https://acme.atlassian.net/browse/OPS-1', 'jira'],
['https://my-app.vercel.app/anything', 'vercel'],
['https://vercel.com/acme-team/my-app', 'vercel'],
+ ['https://resend.com/emails/123', 'resend'],
+ ['https://resend.com/domains/example.com', 'resend'],
])('matches %s to %s', (url, serviceId) => {
expect(matchServiceIdForUrl(url)).toBe(serviceId);
});
@@ -74,6 +76,8 @@ describe('matchSlackMcpSetupService', () => {
'https://developer.monday.com/apps/docs/intro',
'https://developer.monday.com/boards/1234567890',
'https://mcp.monday.com/boards/1234567890',
+ 'https://resend.com/docs/mcp-server',
+ 'https://resend.com/pricing',
])('does not match %s', (url) => {
expect(matchServiceIdForUrl(url)).toBeNull();
});
diff --git a/packages/slack/src/mcp-recommendations.ts b/packages/slack/src/mcp-recommendations.ts
index df3fbab6a..30516ffb7 100644
--- a/packages/slack/src/mcp-recommendations.ts
+++ b/packages/slack/src/mcp-recommendations.ts
@@ -50,6 +50,8 @@ const SLACK_ENABLE_DESCRIPTIONS: Record = {
'Roomote will be able to inspect monitoring, incidents, and telemetry.',
railway:
'Roomote will be able to inspect Railway account, project, and service inventory.',
+ resend:
+ 'Roomote will be able to inspect and manage shared email infrastructure.',
braintrust:
'Roomote will be able to inspect prompts, evaluations, and AI run history.',
linear:
diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts
index e47870d58..9261b71a6 100644
--- a/packages/types/src/__tests__/mcp-oauth.test.ts
+++ b/packages/types/src/__tests__/mcp-oauth.test.ts
@@ -2,10 +2,12 @@ import {
getMcpIntegration,
getMcpIntegrationAuthorizationParameters,
getMcpIntegrationConnectionScope,
+ getMcpIntegrationDefaultDisabledTools,
getMcpIntegrationOauthScopeMode,
getMcpIntegrationOauthScopes,
LINEAR_APP_OAUTH_SCOPES,
MONDAY_MCP_READ_ONLY_OAUTH_SCOPES,
+ RESEND_DEFAULT_DISABLED_TOOL_NAMES,
} from '../mcp-oauth';
describe('Linear OAuth scopes', () => {
@@ -45,3 +47,57 @@ describe('monday.com OAuth', () => {
);
});
});
+
+describe('Resend OAuth', () => {
+ it('uses a deployment-scoped hosted MCP with risky tools disabled initially', () => {
+ expect(getMcpIntegration('resend')).toMatchObject({
+ name: 'Resend',
+ url: 'https://mcp.resend.com/mcp',
+ connectionMode: 'oauth',
+ serverMode: 'upstream_proxy',
+ oauthScopes: ['full_access'],
+ oauthEndpoints: {
+ authorizationEndpoint: 'https://api.resend.com/oauth/authorize',
+ tokenEndpoint: 'https://api.resend.com/oauth/token',
+ registrationEndpoint: 'https://api.resend.com/oauth/register',
+ tokenEndpointAuthMethod: 'none',
+ },
+ });
+ expect(getMcpIntegrationConnectionScope('resend')).toBe('deployment');
+ expect(getMcpIntegrationDefaultDisabledTools('resend')).toEqual(
+ RESEND_DEFAULT_DISABLED_TOOL_NAMES,
+ );
+ expect(RESEND_DEFAULT_DISABLED_TOOL_NAMES).toEqual([
+ 'send-email',
+ 'send-batch-emails',
+ 'send-broadcast',
+ 'update-email',
+ 'create-contact',
+ 'update-contact',
+ 'remove-contact',
+ 'add-contact-to-segment',
+ 'remove-contact-from-segment',
+ 'update-contact-topics',
+ 'create-contact-import',
+ 'create-automation',
+ 'update-automation',
+ 'send-event',
+ 'create-api-key',
+ 'remove-api-key',
+ 'create-contact-property',
+ 'update-contact-property',
+ 'remove-contact-property',
+ 'update-domain',
+ 'remove-domain',
+ 'create-webhook',
+ 'update-webhook',
+ ]);
+ expect(RESEND_DEFAULT_DISABLED_TOOL_NAMES).toHaveLength(23);
+ expect(getMcpIntegrationDefaultDisabledTools('resend')).not.toContain(
+ 'cancel-email',
+ );
+ expect(getMcpIntegrationDefaultDisabledTools('resend')).not.toContain(
+ 'list-contacts',
+ );
+ });
+});
diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts
index e9b138b97..1bb1144a2 100644
--- a/packages/types/src/mcp-oauth.ts
+++ b/packages/types/src/mcp-oauth.ts
@@ -244,6 +244,8 @@ export type McpIntegrationOAuthClientEnv = {
export type McpIntegrationOAuthEndpoints = {
authorizationEndpoint: string;
tokenEndpoint: string;
+ registrationEndpoint?: string;
+ tokenEndpointAuthMethod?: OAuthTokenEndpointAuthMethod;
};
export type McpIntegrationAuthorizationParameter = {
@@ -274,8 +276,35 @@ export type McpIntegration = {
oauthScopeMode?: McpIntegrationOauthScopeMode;
connectionMode?: McpIntegrationConnectionMode;
serverMode?: McpIntegrationServerMode;
+ defaultDisabledTools?: string[];
};
+export const RESEND_DEFAULT_DISABLED_TOOL_NAMES = [
+ 'send-email',
+ 'send-batch-emails',
+ 'send-broadcast',
+ 'update-email',
+ 'create-contact',
+ 'update-contact',
+ 'remove-contact',
+ 'add-contact-to-segment',
+ 'remove-contact-from-segment',
+ 'update-contact-topics',
+ 'create-contact-import',
+ 'create-automation',
+ 'update-automation',
+ 'send-event',
+ 'create-api-key',
+ 'remove-api-key',
+ 'create-contact-property',
+ 'update-contact-property',
+ 'remove-contact-property',
+ 'update-domain',
+ 'remove-domain',
+ 'create-webhook',
+ 'update-webhook',
+] as const;
+
export const MCP_INTEGRATIONS: McpIntegration[] = [
{
id: 'notion',
@@ -423,6 +452,26 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [
connectionScope: 'deployment',
serverMode: 'upstream_proxy',
},
+ {
+ id: 'resend',
+ name: 'Resend',
+ url: 'https://mcp.resend.com/mcp',
+ description: `Inspect and manage shared email infrastructure through Resend from ${PRODUCT_NAME} tasks.`,
+ icon: 'resend',
+ connectionScope: 'deployment',
+ connectionMode: 'oauth',
+ serverMode: 'upstream_proxy',
+ oauthEndpoints: {
+ authorizationEndpoint: 'https://api.resend.com/oauth/authorize',
+ tokenEndpoint: 'https://api.resend.com/oauth/token',
+ registrationEndpoint: 'https://api.resend.com/oauth/register',
+ tokenEndpointAuthMethod: 'none',
+ },
+ oauthScopes: ['full_access'],
+ defaultDisabledTools: [...RESEND_DEFAULT_DISABLED_TOOL_NAMES],
+ instructions:
+ 'Use Resend to inspect email delivery, received messages, domains, contacts, templates, broadcasts, and related infrastructure. Email sending, credential creation and removal, scheduled-send changes, domain and webhook mutations, automation mutations and triggers, and contact mutations are disabled until a deployment admin explicitly enables those tools.',
+ },
{
id: 'braintrust',
name: 'Braintrust',
@@ -644,6 +693,21 @@ export function getMcpIntegrationOauthScopes(
return integration.oauthScopes;
}
+export function getMcpIntegrationDefaultDisabledTools(
+ integrationOrId: McpIntegration | string | undefined,
+): readonly string[] {
+ if (!integrationOrId) {
+ return [];
+ }
+
+ const integration =
+ typeof integrationOrId === 'string'
+ ? getMcpIntegration(integrationOrId)
+ : integrationOrId;
+
+ return integration?.defaultDisabledTools ?? [];
+}
+
export function getMcpIntegrationOauthEndpoints(
integrationOrId: McpIntegration | string | undefined,
): McpIntegrationOAuthEndpoints | undefined {
diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts
index 7b01f7f51..37b381f94 100644
--- a/packages/types/src/mcp-service-detection.ts
+++ b/packages/types/src/mcp-service-detection.ts
@@ -208,6 +208,29 @@ export const SLACK_MCP_SETUP_SERVICES: SlackMcpSetupServiceDefinition[] = [
deploymentSettingsPath: '/settings/integrations',
userSettingsPath: '/settings/personal',
},
+ {
+ id: 'resend',
+ name: 'Resend',
+ availabilityKind: 'curated_oauth',
+ hostSuffixes: ['resend.com'],
+ pathPrefixes: [
+ '/api-keys',
+ '/audiences',
+ '/automations',
+ '/broadcasts',
+ '/contacts',
+ '/domains',
+ '/emails',
+ '/logs',
+ '/segments',
+ '/settings',
+ '/templates',
+ '/topics',
+ '/webhooks',
+ ],
+ deploymentSettingsPath: '/settings/integrations',
+ userSettingsPath: '/settings/personal',
+ },
{
id: 'vercel',
name: 'Vercel',
From e38ba70c67d582bf8db0356fd4b5236e0ff5f300 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 10:24:36 -0400
Subject: [PATCH 09/13] [Fix] Bedrock Mantle models disappear after settings
reload (#1090)
Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com>
---
.../trpc/commands/task-models/auto-add-models.test.ts | 11 +++++++++++
.../src/trpc/commands/task-models/auto-add-models.ts | 11 +++++++++--
2 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/trpc/commands/task-models/auto-add-models.test.ts b/apps/web/src/trpc/commands/task-models/auto-add-models.test.ts
index 455637b77..115c67e01 100644
--- a/apps/web/src/trpc/commands/task-models/auto-add-models.test.ts
+++ b/apps/web/src/trpc/commands/task-models/auto-add-models.test.ts
@@ -350,4 +350,15 @@ describe('collectConnectedTaskModelProviderIds', () => {
// SuperGrok serves xai/ model ids, same alias pattern as ChatGPT→openai.
expect(connected.has('xai')).toBe(true);
});
+
+ it('includes the Mantle model prefix when Amazon Bedrock is connected', () => {
+ const connected = collectConnectedTaskModelProviderIds({
+ runtimeEnv: {},
+ persistedEnvVarNames: ['AWS_BEARER_TOKEN_BEDROCK'],
+ chatgptConnected: false,
+ });
+
+ expect(connected.has('amazon-bedrock')).toBe(true);
+ expect(connected.has('bedrock-mantle')).toBe(true);
+ });
});
diff --git a/apps/web/src/trpc/commands/task-models/auto-add-models.ts b/apps/web/src/trpc/commands/task-models/auto-add-models.ts
index ca7404178..1302a9728 100644
--- a/apps/web/src/trpc/commands/task-models/auto-add-models.ts
+++ b/apps/web/src/trpc/commands/task-models/auto-add-models.ts
@@ -23,7 +23,8 @@ function deriveDisplayNameFromModelId(modelId: string): string {
* Model-id prefixes of the providers currently connected (saved or runtime
* env). A connected ChatGPT subscription serves `openai/` model ids, so it
* contributes `openai` alongside its own `chatgpt` catalog id. SuperGrok
- * similarly contributes `xai` alongside `xai-subscription`.
+ * similarly contributes `xai` alongside `xai-subscription`, and Amazon
+ * Bedrock contributes the `bedrock-mantle` runtime provider prefix.
*/
export function collectConnectedTaskModelProviderIds(options: {
runtimeEnv: Partial>;
@@ -40,7 +41,7 @@ export function collectConnectedTaskModelProviderIds(options: {
xaiSubscriptionConnected: options.xaiSubscriptionConnected,
});
- return new Set([
+ const connectedProviderIds = new Set([
...status.providers
.filter(
(provider) =>
@@ -50,6 +51,12 @@ export function collectConnectedTaskModelProviderIds(options: {
...(options.chatgptConnected ? ['openai'] : []),
...(options.xaiSubscriptionConnected ? ['xai'] : []),
]);
+
+ if (connectedProviderIds.has('amazon-bedrock')) {
+ connectedProviderIds.add('bedrock-mantle');
+ }
+
+ return connectedProviderIds;
}
/**
From 0d1fc81a24432e91e5046f59aaa60c29bc742e75 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 15:51:46 +0100
Subject: [PATCH 10/13] fix: exclude internal tasks from activation telemetry
(#1091)
Co-authored-by: Roomote
---
.../src/server/__tests__/enqueue-task.test.ts | 18 ++++++++++++++++++
.../cloud-agents/src/server/task-run-queue.ts | 13 ++++++++++++-
2 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts
index 7e471ab0d..305907d9b 100644
--- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts
+++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts
@@ -132,6 +132,24 @@ describe('shouldCaptureActivationTaskCreatedEvent', () => {
workflow: 'standard' as const,
initiator: { kind: 'automation', key: 'dependabot_triage' } as const,
},
+ {
+ taskType: TaskPayloadKind.StandardTask,
+ workflow: 'standard' as const,
+ initiator: userInitiator,
+ sourceRunId: 42,
+ },
+ {
+ taskType: TaskPayloadKind.StandardTask,
+ workflow: 'standard' as const,
+ initiator: userInitiator,
+ environmentDefinitionId: 'environment-1',
+ },
+ {
+ taskType: TaskPayloadKind.StandardTask,
+ workflow: 'standard' as const,
+ initiator: userInitiator,
+ verifiesEnvironmentId: 'environment-1',
+ },
])('excludes non-activation task launches', (input) => {
expect(shouldCaptureActivationTaskCreatedEvent(input)).toBe(false);
});
diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts
index 65ea543c0..82d12c813 100644
--- a/packages/cloud-agents/src/server/task-run-queue.ts
+++ b/packages/cloud-agents/src/server/task-run-queue.ts
@@ -122,11 +122,17 @@ export function shouldCaptureActivationTaskCreatedEvent(input: {
taskType: TaskPayloadKind;
workflow: TaskWorkflow;
initiator: TaskInitiator;
+ sourceRunId?: number | null;
+ environmentDefinitionId?: string;
+ verifiesEnvironmentId?: string;
}): boolean {
return (
input.initiator.kind === 'user' &&
input.workflow === 'standard' &&
- input.taskType !== TaskPayloadKind.SnapshotEnvironment
+ input.taskType !== TaskPayloadKind.SnapshotEnvironment &&
+ input.sourceRunId == null &&
+ input.environmentDefinitionId == null &&
+ input.verifiesEnvironmentId == null
);
}
@@ -1585,6 +1591,11 @@ async function enqueueFreshLaunch(
taskType: taskRun.payloadKind,
workflow,
initiator,
+ sourceRunId: taskWithHarnessOverrides.sourceRunId,
+ environmentDefinitionId:
+ taskWithHarnessOverrides.payload.environmentDefinitionId,
+ verifiesEnvironmentId:
+ taskWithHarnessOverrides.payload.verifiesEnvironmentId,
})
) {
void captureActivationTaskCreated({
From 62ea28be4295d904502a8f77db8445db652c84c6 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 11:00:09 -0400
Subject: [PATCH 11/13] [Feat] Add customizable account-linking help (#1088)
Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com>
---
.../src/handlers/account-link-help.test.ts | 41 +++++++
apps/api/src/handlers/account-link-help.ts | 17 +++
.../__tests__/handleWorkItemComment.test.ts | 2 +-
apps/api/src/handlers/ado/handleComment.ts | 2 +-
.../src/handlers/ado/handleWorkItemComment.ts | 2 +-
.../bitbucket/__tests__/handleComment.test.ts | 1 +
.../src/handlers/bitbucket/handleComment.ts | 2 +-
.../discord/__tests__/account-link.test.ts | 28 ++++-
.../handlers/discord/__tests__/index.test.ts | 22 ++++
apps/api/src/handlers/discord/account-link.ts | 29 +++--
.../handlers/discord/channel-auto-start.ts | 2 +-
apps/api/src/handlers/gitea/handleComment.ts | 4 +-
.../github/handleGitHubIssueComment.ts | 4 +-
.../src/handlers/github/handlePrComment.ts | 2 +-
apps/api/src/handlers/gitlab/handleNote.ts | 4 +-
.../source-control-account-linking.test.ts | 32 +++++
.../source-control-account-linking.ts | 14 ++-
.../handlers/telegram/__tests__/index.test.ts | 33 +++++
apps/api/src/handlers/telegram/index.ts | 18 ++-
apps/docs/users.mdx | 14 +++
.../auth-form.client.test.tsx | 5 +-
.../src/app/(unauthenticated)/auth-form.tsx | 3 +
.../(unauthenticated)/email-password-auth.tsx | 20 ++-
.../sign-in/[[...sign-in]]/page.client.tsx | 3 +
.../sign-in/[[...sign-in]]/page.tsx | 14 ++-
.../settings/AccountLinkHelpSection.tsx | 114 ++++++++++++++++++
.../settings/UsersSettings.client.test.tsx | 40 ++++++
.../src/components/settings/UsersSettings.tsx | 3 +
.../trpc/commands/access-policy/index.test.ts | 43 ++++++-
.../src/trpc/commands/access-policy/index.ts | 21 ++++
apps/web/src/trpc/routers/_app.ts | 16 +++
.../lib/account-link-help-settings.test.ts | 62 ++++++++++
.../db/src/lib/account-link-help-settings.ts | 44 +++++++
packages/db/src/server.ts | 1 +
34 files changed, 616 insertions(+), 46 deletions(-)
create mode 100644 apps/api/src/handlers/account-link-help.test.ts
create mode 100644 apps/api/src/handlers/account-link-help.ts
create mode 100644 apps/api/src/handlers/source-control-account-linking.test.ts
create mode 100644 apps/web/src/components/settings/AccountLinkHelpSection.tsx
create mode 100644 packages/db/src/lib/account-link-help-settings.test.ts
create mode 100644 packages/db/src/lib/account-link-help-settings.ts
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/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 f000d1984..ab1b342b7 100644
--- a/apps/api/src/handlers/discord/__tests__/index.test.ts
+++ b/apps/api/src/handlers/discord/__tests__/index.test.ts
@@ -54,6 +54,11 @@ const mocks = vi.hoisted(() => ({
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) => {
@@ -225,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',
@@ -1263,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'));
@@ -1286,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.',
@@ -1294,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({
@@ -1338,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();
});
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/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/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/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/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/(unauthenticated)/auth-form.client.test.tsx b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx
index 596e617f9..110ab4428 100644
--- a/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx
+++ b/apps/web/src/app/(unauthenticated)/auth-form.client.test.tsx
@@ -235,7 +235,9 @@ describe('AuthForm', () => {
});
it('hides account creation and points at an admin without an invite', () => {
- render( );
+ render(
+ ,
+ );
fireEvent.click(
screen.getByRole('button', { name: 'Continue with email' }),
@@ -249,6 +251,7 @@ describe('AuthForm', () => {
screen.getByText(/Need an account\? Forgot your password\?/),
).toBeVisible();
expect(screen.getByText(/Ask your admin\./)).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Talk to us' })).toBeVisible();
});
it('can hide the account and password help copy for bootstrap sign-up', () => {
diff --git a/apps/web/src/app/(unauthenticated)/auth-form.tsx b/apps/web/src/app/(unauthenticated)/auth-form.tsx
index dea256096..bc9273357 100644
--- a/apps/web/src/app/(unauthenticated)/auth-form.tsx
+++ b/apps/web/src/app/(unauthenticated)/auth-form.tsx
@@ -70,6 +70,7 @@ export function AuthForm({
inviteRole = null,
hideModeSwitchMessage = false,
noticeMessage = null,
+ accountLinkHelpText = null,
}: {
enabledProviders?: AuthProvider[];
/**
@@ -88,6 +89,7 @@ export function AuthForm({
* the per-attempt error state.
*/
noticeMessage?: string | null;
+ accountLinkHelpText?: string | null;
}) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -219,6 +221,7 @@ export function AuthForm({
(
@@ -194,11 +197,18 @@ export function EmailPasswordAuth({
)}
{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/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 : (
+ <>
+ setValue(savedValue)}
+ disabled={updateMutation.isPending}
+ >
+ Reset
+
+
+ updateMutation.mutate({ helpText: value.trim() || null })
+ }
+ disabled={updateMutation.isPending}
+ >
+ {updateMutation.isPending ? 'Saving...' : 'Save'}
+
+ >
+ );
+
+ 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.
+
+ ) : (
+
+
+ Account linking help text
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/settings/UsersSettings.client.test.tsx b/apps/web/src/components/settings/UsersSettings.client.test.tsx
index ea8e59beb..7efa018e6 100644
--- a/apps/web/src/components/settings/UsersSettings.client.test.tsx
+++ b/apps/web/src/components/settings/UsersSettings.client.test.tsx
@@ -77,6 +77,8 @@ const {
mockRemoveUser,
mockCreatePasswordResetLink,
mockSetLicenseKey,
+ mockAccountLinkHelpState,
+ mockSetAccountLinkHelp,
mockClipboardWriteText,
mockCapture,
} = vi.hoisted(() => ({
@@ -159,6 +161,13 @@ const {
mockSetLicenseKey: vi.fn(async (_input: { licenseKey: string | null }) => ({
saved: true,
})),
+ mockAccountLinkHelpState: {
+ current: { helpText: null as string | null },
+ },
+ mockSetAccountLinkHelp: vi.fn(async (input: { helpText: string | null }) => {
+ mockAccountLinkHelpState.current = { helpText: input.helpText };
+ return mockAccountLinkHelpState.current;
+ }),
mockClipboardWriteText: vi.fn(async (_value: string) => undefined),
mockCapture: vi.fn(),
}));
@@ -189,6 +198,19 @@ vi.mock('@/trpc/client', () => ({
queryFn: async () => mockSettingsState.current,
}),
},
+ accountLinkHelp: {
+ queryKey: () => ['account-link-help'],
+ queryOptions: () => ({
+ queryKey: ['account-link-help'],
+ queryFn: async () => mockAccountLinkHelpState.current,
+ }),
+ },
+ setAccountLinkHelp: {
+ mutationOptions: (options: Record = {}) => ({
+ mutationFn: mockSetAccountLinkHelp,
+ ...options,
+ }),
+ },
createInvite: {
mutationOptions: (options: Record = {}) => ({
mutationFn: mockCreateInvite,
@@ -256,6 +278,7 @@ describe('UsersSettings', () => {
vi.clearAllMocks();
mockClipboardWriteText.mockResolvedValue(undefined);
mockAuthorizedUser.current = { userId: 'user-1', cloudEnabled: false };
+ mockAccountLinkHelpState.current = { helpText: null };
mockSettingsState.current = {
slackTeamId: null,
hasSlackSignIn: false,
@@ -266,6 +289,23 @@ describe('UsersSettings', () => {
};
});
+ it('saves deployment-specific account linking help', async () => {
+ renderUsersSettings();
+
+ const textarea = await screen.findByLabelText('Account linking help text');
+ fireEvent.change(textarea, {
+ target: { value: 'Ask an admin: https://discord.gg/example' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ await waitFor(() => {
+ expect(mockSetAccountLinkHelp.mock.calls[0]?.[0]).toEqual({
+ helpText: 'Ask an admin: https://discord.gg/example',
+ });
+ expect(toast.success).toHaveBeenCalledWith('Account linking help saved.');
+ });
+ });
+
it('shows org membership rows only for configured org providers', async () => {
mockSettingsState.current = {
...mockSettingsState.current,
diff --git a/apps/web/src/components/settings/UsersSettings.tsx b/apps/web/src/components/settings/UsersSettings.tsx
index 7823837c3..1e789e66e 100644
--- a/apps/web/src/components/settings/UsersSettings.tsx
+++ b/apps/web/src/components/settings/UsersSettings.tsx
@@ -39,6 +39,7 @@ import {
Users,
} from '@/components/system';
import { Section } from '@/components/settings';
+import { AccountLinkHelpSection } from './AccountLinkHelpSection';
import { formatDistanceToNow } from 'date-fns';
const LICENSE_PURCHASE_URL =
@@ -621,6 +622,8 @@ export function UsersSettings() {
+
+
{
diff --git a/apps/web/src/trpc/commands/access-policy/index.test.ts b/apps/web/src/trpc/commands/access-policy/index.test.ts
index bd870367a..3ef7f6f6a 100644
--- a/apps/web/src/trpc/commands/access-policy/index.test.ts
+++ b/apps/web/src/trpc/commands/access-policy/index.test.ts
@@ -7,13 +7,22 @@ type TestUser = {
createdAt: Date;
};
-const { state, getEnvLicenseKeyMock } = vi.hoisted(() => ({
+const {
+ state,
+ getEnvLicenseKeyMock,
+ getAccountLinkHelpMock,
+ setAccountLinkHelpMock,
+} = vi.hoisted(() => ({
state: {
users: [] as TestUser[],
credentialUserIds: [] as string[],
createdResetLinkForUserId: null as string | null,
},
getEnvLicenseKeyMock: vi.fn(() => null as string | null),
+ getAccountLinkHelpMock: vi.fn(async () => null as string | null),
+ setAccountLinkHelpMock: vi.fn(async (value: string | null) =>
+ value?.trim() ? value.trim() : null,
+ ),
}));
vi.mock('@roomote/db/server', () => {
@@ -45,8 +54,10 @@ vi.mock('@roomote/db/server', () => {
},
},
eq: vi.fn(),
+ getDeploymentAccountLinkHelpText: getAccountLinkHelpMock,
inArray: vi.fn(),
isNull: vi.fn(),
+ setDeploymentAccountLinkHelpText: setAccountLinkHelpMock,
users,
};
});
@@ -99,8 +110,10 @@ vi.mock('@/lib/server/auth-provider-config', () => ({
import {
createPasswordResetLinkCommand,
+ getAccountLinkHelpCommand,
getAccessPolicySettingsCommand,
setLicenseKeyCommand,
+ setAccountLinkHelpCommand,
} from './index';
describe('access policy commands', () => {
@@ -109,6 +122,12 @@ describe('access policy commands', () => {
state.credentialUserIds = [];
state.createdResetLinkForUserId = null;
getEnvLicenseKeyMock.mockReturnValue(null);
+ getAccountLinkHelpMock.mockClear();
+ getAccountLinkHelpMock.mockResolvedValue(null);
+ setAccountLinkHelpMock.mockClear();
+ setAccountLinkHelpMock.mockImplementation(async (value: string | null) =>
+ value?.trim() ? value.trim() : null,
+ );
});
it('marks active users that have credential accounts', async () => {
@@ -178,6 +197,28 @@ describe('access policy commands', () => {
expect(state.createdResetLinkForUserId).toBeNull();
});
+ it('gets and updates account linking help for admins', async () => {
+ getAccountLinkHelpMock.mockResolvedValue('Ask an admin for an invite.');
+
+ await expect(
+ getAccountLinkHelpCommand({ isAdmin: true } as never),
+ ).resolves.toEqual({ helpText: 'Ask an admin for an invite.' });
+ await expect(
+ setAccountLinkHelpCommand({ isAdmin: true } as never, {
+ helpText: ' Join our community. ',
+ }),
+ ).resolves.toEqual({ helpText: 'Join our community.' });
+ });
+
+ it('rejects account linking help changes from non-admins', async () => {
+ await expect(
+ setAccountLinkHelpCommand({ isAdmin: false } as never, {
+ helpText: 'Join our community.',
+ }),
+ ).rejects.toThrow('Unauthorized');
+ expect(setAccountLinkHelpMock).not.toHaveBeenCalled();
+ });
+
it('returns generated password reset links for admins', async () => {
const result = await createPasswordResetLinkCommand(
{ isAdmin: true } as never,
diff --git a/apps/web/src/trpc/commands/access-policy/index.ts b/apps/web/src/trpc/commands/access-policy/index.ts
index 09cef036f..d01f08884 100644
--- a/apps/web/src/trpc/commands/access-policy/index.ts
+++ b/apps/web/src/trpc/commands/access-policy/index.ts
@@ -5,8 +5,10 @@ import {
db,
deploymentSettings,
eq,
+ getDeploymentAccountLinkHelpText,
inArray,
isNull,
+ setDeploymentAccountLinkHelpText,
users,
} from '@roomote/db/server';
import { randomUUID } from 'node:crypto';
@@ -35,6 +37,25 @@ import { resolveAuthProviderConfig } from '@/lib/server/auth-provider-config';
const DEFAULT_DEPLOYMENT_ID = 'default';
+export async function getAccountLinkHelpCommand(
+ auth: UserAuthSuccess,
+): Promise<{ helpText: string | null }> {
+ assertAdmin(auth);
+
+ return { helpText: await getDeploymentAccountLinkHelpText() };
+}
+
+export async function setAccountLinkHelpCommand(
+ auth: UserAuthSuccess,
+ input: { helpText: string | null },
+): Promise<{ helpText: string | null }> {
+ assertAdmin(auth);
+
+ return {
+ helpText: await setDeploymentAccountLinkHelpText(input.helpText),
+ };
+}
+
function assertAdmin(auth: UserAuthSuccess): asserts auth is UserAuthSuccess {
if (!auth.isAdmin) {
throw new Error('Unauthorized');
diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts
index f47dd2914..d510672dd 100644
--- a/apps/web/src/trpc/routers/_app.ts
+++ b/apps/web/src/trpc/routers/_app.ts
@@ -311,10 +311,12 @@ import {
import {
createPasswordResetLinkCommand,
createInviteCommand,
+ getAccountLinkHelpCommand,
getAccessPolicySettingsCommand,
removeUserCommand,
revokeInviteCommand,
setLicenseKeyCommand,
+ setAccountLinkHelpCommand,
updateUserRoleCommand,
} from '../commands/access-policy';
import {
@@ -2564,6 +2566,20 @@ export const appRouter = createRouter({
getAccessPolicySettingsCommand(auth),
),
+ accountLinkHelp: protectedProcedure.query(({ ctx: { auth } }) =>
+ getAccountLinkHelpCommand(auth),
+ ),
+
+ setAccountLinkHelp: protectedProcedure
+ .input(
+ z.object({
+ helpText: z.string().trim().max(1000).nullable(),
+ }),
+ )
+ .mutation(({ ctx: { auth }, input }) =>
+ setAccountLinkHelpCommand(auth, input),
+ ),
+
createInvite: protectedProcedure
.input(
z.object({
diff --git a/packages/db/src/lib/account-link-help-settings.test.ts b/packages/db/src/lib/account-link-help-settings.test.ts
new file mode 100644
index 000000000..db8dad2cd
--- /dev/null
+++ b/packages/db/src/lib/account-link-help-settings.test.ts
@@ -0,0 +1,62 @@
+import type { DatabaseOrTransaction } from '../db';
+import {
+ getDeploymentAccountLinkHelpText,
+ setDeploymentAccountLinkHelpText,
+} from './account-link-help-settings';
+
+function executorWithMetadata(metadata: Record | null) {
+ const where = vi.fn().mockResolvedValue(undefined);
+ const set = vi.fn().mockReturnValue({ where });
+ const update = vi.fn().mockReturnValue({ set });
+
+ return {
+ executor: {
+ query: {
+ deploymentSettings: {
+ findFirst: vi.fn().mockResolvedValue(metadata ? { metadata } : null),
+ },
+ },
+ update,
+ } as unknown as DatabaseOrTransaction,
+ set,
+ };
+}
+
+describe('account link help settings', () => {
+ it('returns normalized help text when configured', async () => {
+ const { executor } = executorWithMetadata({
+ account_link_help_text: ' Ask an admin for an invite. ',
+ });
+
+ await expect(getDeploymentAccountLinkHelpText({ executor })).resolves.toBe(
+ 'Ask an admin for an invite.',
+ );
+ });
+
+ it('returns null when the setting is absent or blank', async () => {
+ const absent = executorWithMetadata(null);
+ const blank = executorWithMetadata({ account_link_help_text: ' ' });
+
+ await expect(
+ getDeploymentAccountLinkHelpText({ executor: absent.executor }),
+ ).resolves.toBeNull();
+ await expect(
+ getDeploymentAccountLinkHelpText({ executor: blank.executor }),
+ ).resolves.toBeNull();
+ });
+
+ it('normalizes the persisted value', async () => {
+ const { executor, set } = executorWithMetadata(null);
+
+ await expect(
+ setDeploymentAccountLinkHelpText(' Ask an admin. ', { executor }),
+ ).resolves.toBe('Ask an admin.');
+ expect(set).toHaveBeenCalledWith(
+ expect.objectContaining({ updatedAt: expect.any(Date) }),
+ );
+
+ await expect(
+ setDeploymentAccountLinkHelpText(' ', { executor }),
+ ).resolves.toBeNull();
+ });
+});
diff --git a/packages/db/src/lib/account-link-help-settings.ts b/packages/db/src/lib/account-link-help-settings.ts
new file mode 100644
index 000000000..ee77e4814
--- /dev/null
+++ b/packages/db/src/lib/account-link-help-settings.ts
@@ -0,0 +1,44 @@
+import { eq, sql } from 'drizzle-orm';
+
+import { type DatabaseOrTransaction, db } from '../db';
+import { deploymentSettings } from '../schema';
+
+const DEFAULT_DEPLOYMENT_ID = 'default';
+const ACCOUNT_LINK_HELP_TEXT_METADATA_KEY = 'account_link_help_text';
+
+export async function getDeploymentAccountLinkHelpText(
+ options: { executor?: DatabaseOrTransaction } = {},
+): Promise {
+ const executor = options.executor ?? db;
+ const deployment = await executor.query.deploymentSettings.findFirst({
+ where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID),
+ columns: { metadata: true },
+ });
+ const metadata = deployment?.metadata as
+ | Record
+ | null
+ | undefined;
+ const value = metadata?.[ACCOUNT_LINK_HELP_TEXT_METADATA_KEY];
+
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
+}
+
+export async function setDeploymentAccountLinkHelpText(
+ helpText: string | null,
+ options: { executor?: DatabaseOrTransaction } = {},
+): Promise {
+ const executor = options.executor ?? db;
+ const normalizedHelpText = helpText?.trim() || null;
+
+ await executor
+ .update(deploymentSettings)
+ .set({
+ metadata: sql`${deploymentSettings.metadata} || ${JSON.stringify({
+ [ACCOUNT_LINK_HELP_TEXT_METADATA_KEY]: normalizedHelpText,
+ })}::jsonb`,
+ updatedAt: new Date(),
+ })
+ .where(eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID));
+
+ return normalizedHelpText;
+}
diff --git a/packages/db/src/server.ts b/packages/db/src/server.ts
index 46f6c6886..7a3666f42 100644
--- a/packages/db/src/server.ts
+++ b/packages/db/src/server.ts
@@ -77,6 +77,7 @@ export * from './lib/discord-runtime-credentials';
export * from './lib/router-debug-settings';
export * from './lib/pr-action-settings';
export * from './lib/github-mention-settings';
+export * from './lib/account-link-help-settings';
export * from './lib/setup-qualification';
export * from './lib/repositories';
export * from './lib/telemetry-ids';
From 54bacdff47d3ad2c468539b886e4fb54a91cab85 Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 11:14:47 -0400
Subject: [PATCH 12/13] [Fix] Amazon Bedrock models appear in duplicate
settings sections (#1093)
Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com>
---
.../types/src/model-provider-config.test.ts | 30 +++++++++++++++++++
packages/types/src/model-provider-config.ts | 4 +++
2 files changed, 34 insertions(+)
diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts
index a33e6bbf2..f3f5b81fa 100644
--- a/packages/types/src/model-provider-config.test.ts
+++ b/packages/types/src/model-provider-config.test.ts
@@ -679,6 +679,36 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => {
).toBe('xai');
});
+ it('groups native Bedrock and Mantle models under Amazon Bedrock', () => {
+ expect(
+ groupModelsByDisplayProvider([
+ {
+ id: 'amazon-bedrock/anthropic.claude-opus-4-8',
+ displayName: 'Claude Opus 4.8',
+ },
+ {
+ id: 'bedrock-mantle/anthropic.claude-sonnet-5',
+ displayName: 'Claude Sonnet 5',
+ },
+ ]),
+ ).toEqual([
+ {
+ providerId: 'amazon-bedrock',
+ label: 'Amazon Bedrock',
+ items: [
+ {
+ id: 'amazon-bedrock/anthropic.claude-opus-4-8',
+ displayName: 'Claude Opus 4.8',
+ },
+ {
+ id: 'bedrock-mantle/anthropic.claude-sonnet-5',
+ displayName: 'Claude Sonnet 5',
+ },
+ ],
+ },
+ ]);
+ });
+
it('groups model chooser options by display provider and catalog order', () => {
const groups = groupModelsByDisplayProvider(
[
diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts
index 047e5b671..1a32ec69c 100644
--- a/packages/types/src/model-provider-config.ts
+++ b/packages/types/src/model-provider-config.ts
@@ -1392,6 +1392,10 @@ export function getDisplayModelProviderId(
const runtimeProviderId = getTaskModelProviderId(normalizedModelId);
+ if (runtimeProviderId === 'bedrock-mantle') {
+ return 'amazon-bedrock';
+ }
+
if (
runtimeProviderId === 'openai' &&
options?.chatgptConnected &&
From c3e7722c3553f0df332904e1a023cba9a2c5ddae Mon Sep 17 00:00:00 2001
From: "roomote-roomote[bot]"
<301996811+roomote-roomote[bot]@users.noreply.github.com>
Date: Tue, 4 Aug 2026 11:30:01 -0400
Subject: [PATCH 13/13] Release Roomote 0.33.0 (#1094)
Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com>
---
CHANGELOG.md | 23 +++++++++++++++++++++++
package.json | 2 +-
2 files changed, 24 insertions(+), 1 deletion(-)
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/package.json b/package.json
index 25bad98ff..e5ca54906 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "roomote",
- "version": "0.32.1",
+ "version": "0.33.0",
"license": "FCL-1.0-ALv2",
"packageManager": "pnpm@10.29.3",
"engines": {