diff --git a/apps/discord-bot/src/features/MentionRouter.test.ts b/apps/discord-bot/src/features/MentionRouter.test.ts index 707c6368c7f0..9af5f2851ebd 100644 --- a/apps/discord-bot/src/features/MentionRouter.test.ts +++ b/apps/discord-bot/src/features/MentionRouter.test.ts @@ -33,6 +33,16 @@ describe("T3 connect-wait queue", () => { }); }); +describe("unmentioned Discord replies", () => { + it("routes mentions through explicit-mention policy and skips reply pings", () => { + expect(mentionRouterSource).toContain("discordEventMentionsBot"); + expect(mentionRouterSource).toContain("shouldAcceptThreadTalkMessage"); + expect(mentionRouterSource).toContain( + "Ignoring Discord reply ping without an in-content mention", + ); + }); +}); + describe("today-recap slash command", () => { it("registers /omegent today-recap and opens a recap thread on the project channel", () => { expect(mentionRouterSource).toContain('"today-recap": Effect.gen(function* () {'); diff --git a/apps/discord-bot/src/features/MentionRouter.ts b/apps/discord-bot/src/features/MentionRouter.ts index cd6480238b17..95305ea85b74 100644 --- a/apps/discord-bot/src/features/MentionRouter.ts +++ b/apps/discord-bot/src/features/MentionRouter.ts @@ -192,8 +192,10 @@ import { BridgeHub } from "./BridgeHub.ts"; import { bridgeThreadToDiscord, getLiveDiscordBridge } from "./ResponseBridge.ts"; import { upsertThreadInfoPin } from "./ThreadInfoPin.ts"; import { + discordEventMentionsBot, formatUnmentionedDiscordPrompt, parseThreadTalkCommand, + shouldAcceptThreadTalkMessage, threadTalkEnabled, } from "./ThreadTalkPolicy.ts"; @@ -266,21 +268,6 @@ function isThreadChannel(type: number | undefined): boolean { return type === 10 || type === 11 || type === 12; } -function mentionsBotInContent(content: string, botUserId: string): boolean { - return content.includes(`<@${botUserId}>`) || content.includes(`<@!${botUserId}>`); -} - -function mentionsBotInEvent( - event: { - readonly content?: string | null; - readonly mentions?: ReadonlyArray<{ readonly id?: string }> | null; - }, - botUserId: string, -): boolean { - if (mentionsBotInContent(event.content ?? "", botUserId)) return true; - return event.mentions?.some((user) => user.id === botUserId) ?? false; -} - function discordMessageFromEvent(event: { readonly id: string; readonly content?: string | null | undefined; @@ -2318,26 +2305,40 @@ const make = (botConfig: DiscordBotConfig) => let content = event.content ?? ""; let gatewayAttachments = (event.attachments ?? []) as ReadonlyArray; - let mentioned = - mentionsBotInEvent( - { - content: event.content ?? null, - mentions: event.mentions ?? null, - }, + const replyPingWithoutBody = + event.type === Discord.MessageType.REPLY && + content.length === 0 && + (mentionIds.includes(botUserId) || mentionsBotRole); + + const computeMentioned = (body: string) => + discordEventMentionsBot({ + content: body, + mentions: event.mentions ?? null, + mentionRoleIds, botUserId, - ) || - mentionsBotInContent(content, botUserId) || - mentionsBotRole; + botRoleId, + messageType: event.type, + }); - if (!mentioned && content.includes(botUserId)) { - mentioned = true; - } + let mentioned = computeMentioned(content); - const unmentionedLink = mentioned + let unmentionedLink = mentioned ? null : yield* links.getByDiscordThreadId(event.channel_id); - const automaticThreadMessage = !mentioned && threadTalkEnabled(unmentionedLink); - if (!mentioned && !automaticThreadMessage) return; + let automaticThreadMessage = shouldAcceptThreadTalkMessage({ + mentioned, + threadTalkEnabled: threadTalkEnabled(unmentionedLink), + messageType: event.type, + }); + if (!mentioned && !automaticThreadMessage && !replyPingWithoutBody) { + if (event.type === Discord.MessageType.REPLY && mentionIds.includes(botUserId)) { + yield* Effect.logInfo("Ignoring Discord reply ping without an in-content mention", { + channelId: event.channel_id, + messageId: event.id, + }); + } + return; + } if (content.length === 0) { yield* Effect.logWarning( @@ -2359,6 +2360,22 @@ const make = (botConfig: DiscordBotConfig) => gatewayAttachments = full.attachments as ReadonlyArray; } } + mentioned = computeMentioned(content); + unmentionedLink = mentioned ? null : yield* links.getByDiscordThreadId(event.channel_id); + automaticThreadMessage = shouldAcceptThreadTalkMessage({ + mentioned, + threadTalkEnabled: threadTalkEnabled(unmentionedLink), + messageType: event.type, + }); + if (!mentioned && !automaticThreadMessage) { + if (event.type === Discord.MessageType.REPLY) { + yield* Effect.logInfo("Ignoring Discord reply ping without an in-content mention", { + channelId: event.channel_id, + messageId: event.id, + }); + } + return; + } } if ( @@ -2438,7 +2455,7 @@ const make = (botConfig: DiscordBotConfig) => threadTalkCommand.kind === "set" ? threadTalkCommand.enabled : threadTalkEnabled(link); yield* rest.createMessage(event.channel_id, { content: enabled - ? "Thread-talk is **on**. New human messages in this linked thread will be sent to T3 without requiring a mention." + ? "Thread-talk is **on**. New human messages in this linked thread will be sent to T3 without requiring a mention. Replies still need `@Omegent`." : "Thread-talk is **off**. Mention `@Omegent` to send a message to Omegent.", message_reference: { message_id: event.id }, }); diff --git a/apps/discord-bot/src/features/ThreadTalkPolicy.test.ts b/apps/discord-bot/src/features/ThreadTalkPolicy.test.ts index 8a04ad4cdc13..21cee1798886 100644 --- a/apps/discord-bot/src/features/ThreadTalkPolicy.test.ts +++ b/apps/discord-bot/src/features/ThreadTalkPolicy.test.ts @@ -1,10 +1,14 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { Discord } from "dfx"; import { describe, expect, it } from "vite-plus/test"; import type { ThreadLink } from "../store/ThreadLinkStore.ts"; import { + DISCORD_REPLY_MESSAGE_TYPE, + discordEventMentionsBot, formatUnmentionedDiscordPrompt, parseThreadTalkCommand, + shouldAcceptThreadTalkMessage, threadTalkEnabled, } from "./ThreadTalkPolicy.ts"; @@ -52,6 +56,103 @@ describe("threadTalkEnabled", () => { }); }); +describe("discordEventMentionsBot", () => { + const botUserId = "bot-1"; + const botRoleId = "role-1"; + + it("tracks Discord MessageType.REPLY", () => { + expect(DISCORD_REPLY_MESSAGE_TYPE).toBe(Discord.MessageType.REPLY); + }); + + it("treats in-content user and role mentions as addressing the bot", () => { + expect( + discordEventMentionsBot({ + content: `hey <@${botUserId}> look`, + botUserId, + }), + ).toBe(true); + expect( + discordEventMentionsBot({ + content: "hey", + mentionRoleIds: [botRoleId], + botUserId, + botRoleId, + }), + ).toBe(true); + }); + + it("treats the mentions array as addressing the bot on ordinary messages", () => { + expect( + discordEventMentionsBot({ + content: "please check this", + mentions: [{ id: botUserId }], + botUserId, + }), + ).toBe(true); + }); + + it("ignores reply pings that only put the bot in the mentions array", () => { + expect( + discordEventMentionsBot({ + content: "quoting this for context", + mentions: [{ id: botUserId }], + botUserId, + messageType: DISCORD_REPLY_MESSAGE_TYPE, + }), + ).toBe(false); + }); + + it("still honors an explicit @mention on a reply", () => { + expect( + discordEventMentionsBot({ + content: `<@${botUserId}> check the quoted message`, + mentions: [{ id: botUserId }], + botUserId, + messageType: DISCORD_REPLY_MESSAGE_TYPE, + }), + ).toBe(true); + expect( + discordEventMentionsBot({ + content: "check the quoted message", + mentionRoleIds: [botRoleId], + botUserId, + botRoleId, + messageType: DISCORD_REPLY_MESSAGE_TYPE, + }), + ).toBe(true); + }); +}); + +describe("shouldAcceptThreadTalkMessage", () => { + it("accepts unmentioned non-reply messages when thread-talk is on", () => { + expect( + shouldAcceptThreadTalkMessage({ + mentioned: false, + threadTalkEnabled: true, + }), + ).toBe(true); + }); + + it("does not consume unmentioned replies even when thread-talk is on", () => { + expect( + shouldAcceptThreadTalkMessage({ + mentioned: false, + threadTalkEnabled: true, + messageType: DISCORD_REPLY_MESSAGE_TYPE, + }), + ).toBe(false); + }); + + it("never thread-talks a message that already mentioned the bot", () => { + expect( + shouldAcceptThreadTalkMessage({ + mentioned: true, + threadTalkEnabled: true, + }), + ).toBe(false); + }); +}); + it("labels unmentioned prompts with Discord author and message context", () => { expect( formatUnmentionedDiscordPrompt({ diff --git a/apps/discord-bot/src/features/ThreadTalkPolicy.ts b/apps/discord-bot/src/features/ThreadTalkPolicy.ts index 571b828c51b2..9a90fa017610 100644 --- a/apps/discord-bot/src/features/ThreadTalkPolicy.ts +++ b/apps/discord-bot/src/features/ThreadTalkPolicy.ts @@ -4,6 +4,9 @@ export type ThreadTalkCommand = | { readonly kind: "set"; readonly enabled: boolean } | { readonly kind: "status" }; +/** Discord `MessageType.REPLY`. People use these as quotes, not as addressing the bot. */ +export const DISCORD_REPLY_MESSAGE_TYPE = 19; + export function parseThreadTalkCommand(raw: string): ThreadTalkCommand | null { const normalized = raw.trim().replace(/\s+/gu, " ").toLocaleLowerCase(); if (normalized === "thread-talk on") return { kind: "set", enabled: true }; @@ -16,6 +19,48 @@ export function threadTalkEnabled(link: ThreadLink | null): boolean { return link?.threadTalkMode === "all-messages"; } +export function isDiscordReplyMessage(messageType: number | undefined): boolean { + return messageType === DISCORD_REPLY_MESSAGE_TYPE; +} + +export function mentionsBotInContent(content: string, botUserId: string): boolean { + return content.includes(`<@${botUserId}>`) || content.includes(`<@!${botUserId}>`); +} + +/** + * True when the author addressed the bot, not merely reply-pinged it. + * Discord puts the parent author in `mentions` for a reply ping without `<@id>` in content. + */ +export function discordEventMentionsBot(input: { + readonly content: string; + readonly mentions?: ReadonlyArray<{ readonly id?: string }> | null; + readonly mentionRoleIds?: ReadonlyArray | null; + readonly botUserId: string; + readonly botRoleId?: string | null; + readonly messageType?: number | undefined; +}): boolean { + if (mentionsBotInContent(input.content, input.botUserId)) return true; + if ( + input.botRoleId !== null && + input.botRoleId !== undefined && + (input.mentionRoleIds ?? []).includes(input.botRoleId) + ) { + return true; + } + if (isDiscordReplyMessage(input.messageType)) return false; + if (input.content.includes(input.botUserId)) return true; + return input.mentions?.some((user) => user.id === input.botUserId) ?? false; +} + +/** Thread-talk never consumes replies; those are quotes unless they @mention the bot. */ +export function shouldAcceptThreadTalkMessage(input: { + readonly mentioned: boolean; + readonly threadTalkEnabled: boolean; + readonly messageType?: number | undefined; +}): boolean { + return !input.mentioned && input.threadTalkEnabled && !isDiscordReplyMessage(input.messageType); +} + export function formatUnmentionedDiscordPrompt(input: { readonly content: string; readonly authorId: string; diff --git a/apps/discord-bot/src/presentation/slashCommands.test.ts b/apps/discord-bot/src/presentation/slashCommands.test.ts index 9895a040cd7e..896d1df7e224 100644 --- a/apps/discord-bot/src/presentation/slashCommands.test.ts +++ b/apps/discord-bot/src/presentation/slashCommands.test.ts @@ -47,6 +47,7 @@ describe("Omegent slash command definition", () => { const threadTalk = OMEGENT_SLASH_COMMAND.options.find( (option) => option.name === "thread-talk", ); + expect(threadTalk?.description).toContain("replies still need @Omegent"); expect(threadTalk?.options?.[0]?.name).toBe("action"); expect(threadTalk?.options?.[0]?.choices?.map((choice) => choice.value)).toEqual([ "on", @@ -115,7 +116,12 @@ describe("slash reply helpers", () => { const onReply = threadTalkSlashReply({ action: "on", enabled: true }); expect(onReply).toMatchObject({ type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, - data: { content: expect.stringContaining("Thread-talk is **on**") }, + data: { + content: expect.stringContaining("Thread-talk is **on**"), + }, + }); + expect(onReply).toMatchObject({ + data: { content: expect.stringContaining("Replies still need `@Omegent`") }, }); expect(onReply).not.toMatchObject({ data: { flags: Discord.MessageFlags.Ephemeral } }); diff --git a/apps/discord-bot/src/presentation/slashCommands.ts b/apps/discord-bot/src/presentation/slashCommands.ts index bddf1957808e..115a5436f72d 100644 --- a/apps/discord-bot/src/presentation/slashCommands.ts +++ b/apps/discord-bot/src/presentation/slashCommands.ts @@ -164,7 +164,7 @@ export const OMEGENT_SLASH_COMMAND = { { type: Discord.ApplicationCommandOptionType.SUB_COMMAND, name: "thread-talk", - description: "Mention-free replies in this linked thread", + description: "Mention-free messages in this linked thread (replies still need @Omegent)", options: [ { type: Discord.ApplicationCommandOptionType.STRING, @@ -260,7 +260,7 @@ export function threadTalkSlashReply(input: { readonly enabled: boolean; }): ReturnType { const content = input.enabled - ? "Thread-talk is **on**. New human messages in this linked thread will be sent to Omegent without requiring a mention." + ? "Thread-talk is **on**. New human messages in this linked thread will be sent to Omegent without requiring a mention. Replies still need `@Omegent`." : "Thread-talk is **off**. Mention `@Omegent` or use `/omegent` to send a message to Omegent."; // on/off change shared thread policy → public; status is personal return slashReply(content, { ephemeral: input.action === "status" });