Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/discord-bot/src/features/MentionRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {');
Expand Down
79 changes: 48 additions & 31 deletions apps/discord-bot/src/features/MentionRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2318,26 +2305,40 @@ const make = (botConfig: DiscordBotConfig) =>
let content = event.content ?? "";
let gatewayAttachments = (event.attachments ??
[]) as ReadonlyArray<DiscordInboundAttachment>;
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(
Expand All @@ -2359,6 +2360,22 @@ const make = (botConfig: DiscordBotConfig) =>
gatewayAttachments = full.attachments as ReadonlyArray<DiscordInboundAttachment>;
}
}
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 (
Expand Down Expand Up @@ -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 },
});
Expand Down
101 changes: 101 additions & 0 deletions apps/discord-bot/src/features/ThreadTalkPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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({
Expand Down
45 changes: 45 additions & 0 deletions apps/discord-bot/src/features/ThreadTalkPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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<string> | 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;
Expand Down
8 changes: 7 additions & 1 deletion apps/discord-bot/src/presentation/slashCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 } });

Expand Down
4 changes: 2 additions & 2 deletions apps/discord-bot/src/presentation/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -260,7 +260,7 @@ export function threadTalkSlashReply(input: {
readonly enabled: boolean;
}): ReturnType<typeof Ix.response> {
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" });
Expand Down
Loading