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