From a5d8f09fd496bb5d2e874cae94ffb89873d4bf0f Mon Sep 17 00:00:00 2001 From: Chunyue Wang <80630709+openperf@users.noreply.github.com> Date: Sun, 31 May 2026 22:52:59 +0800 Subject: [PATCH] fix(discord): ping mention-bearing final replies Fixes #88360. Route Discord live-preview final replies containing targeted user or role mentions through fresh message delivery instead of edit finalization, preserving mention alias rewriting and notification behavior. Plain, broadcast-only, and mixed targeted-plus-broadcast replies keep the existing preview edit path. Proof: CI run 26708866609 green for relevant lanes; Real behavior proof run 26708866194 successful; local git diff --check and git merge-tree clean. --- extensions/discord/src/mentions.test.ts | 33 ++++++- extensions/discord/src/mentions.ts | 12 +++ .../monitor/message-handler.process.test.ts | 89 +++++++++++++++++++ .../src/monitor/message-handler.process.ts | 18 +++- 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/extensions/discord/src/mentions.test.ts b/extensions/discord/src/mentions.test.ts index 5ab439c62e0..949d57d8ea4 100644 --- a/extensions/discord/src/mentions.test.ts +++ b/extensions/discord/src/mentions.test.ts @@ -3,7 +3,12 @@ import { resetDiscordDirectoryCacheForTest, rememberDiscordDirectoryUser, } from "./directory-cache.js"; -import { formatMention, rewriteDiscordKnownMentions } from "./mentions.js"; +import { + discordTextHasBroadcastMention, + discordTextHasTargetedMention, + formatMention, + rewriteDiscordKnownMentions, +} from "./mentions.js"; describe("formatMention", () => { it("formats user mentions from ids", () => { @@ -109,3 +114,29 @@ describe("rewriteDiscordKnownMentions", () => { expect(opsRewrite).toBe("<@999888777>"); }); }); + +describe("discordTextHasTargetedMention", () => { + it("detects user and role mentions", () => { + expect(discordTextHasTargetedMention("ping <@123>")).toBe(true); + expect(discordTextHasTargetedMention("ping <@!123>")).toBe(true); + expect(discordTextHasTargetedMention("ping <@&456>")).toBe(true); + }); + + it("ignores plain text, channels, and broadcasts", () => { + expect(discordTextHasTargetedMention("ping @alice")).toBe(false); + expect(discordTextHasTargetedMention("see <#789>")).toBe(false); + expect(discordTextHasTargetedMention("heads up @everyone @here")).toBe(false); + }); +}); + +describe("discordTextHasBroadcastMention", () => { + it("detects @everyone and @here", () => { + expect(discordTextHasBroadcastMention("heads up @everyone")).toBe(true); + expect(discordTextHasBroadcastMention("@here please")).toBe(true); + }); + + it("ignores targeted mentions and lookalikes", () => { + expect(discordTextHasBroadcastMention("ping <@123>")).toBe(false); + expect(discordTextHasBroadcastMention("mail me at a@everyones")).toBe(false); + }); +}); diff --git a/extensions/discord/src/mentions.ts b/extensions/discord/src/mentions.ts index 23a74988158..17fe201c590 100644 --- a/extensions/discord/src/mentions.ts +++ b/extensions/discord/src/mentions.ts @@ -11,6 +11,8 @@ const MARKDOWN_CODE_SEGMENT_PATTERN = /```[\s\S]*?```|`[^`\n]*`/g; const MENTION_CANDIDATE_PATTERN = /(^|[\s([{"'.,;:!?])@([a-z0-9_.-]{2,32}(?:#[0-9]{4})?)/gi; const DISCORD_RESERVED_MENTIONS = new Set(["everyone", "here"]); const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/; +const DISCORD_TARGETED_MENTION_PATTERN = /<@!?\d+>|<@&\d+>/; +const DISCORD_BROADCAST_MENTION_PATTERN = /@(everyone|here)\b/; function normalizeSnowflake(value: string | number | bigint): string | null { const text = normalizeOptionalStringifiedId(value) ?? ""; @@ -145,3 +147,13 @@ export function rewriteDiscordKnownMentions( rewritten += rewritePlainTextMentions(text.slice(offset), params); return rewritten; } + +/** Whether text carries a Discord user/role mention (`<@id>`, `<@!id>`, `<@&id>`) that pings when sent fresh. */ +export function discordTextHasTargetedMention(text: string): boolean { + return DISCORD_TARGETED_MENTION_PATTERN.test(text); +} + +/** Whether text carries an `@everyone`/`@here` broadcast mention. */ +export function discordTextHasBroadcastMention(text: string): boolean { + return DISCORD_BROADCAST_MENTION_PATTERN.test(text); +} diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 98b2b36df8d..fe30f728061 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -1952,6 +1952,95 @@ describe("processDiscordMessage draft streaming", () => { expectSinglePreviewEdit(); }); + it("delivers a fresh message instead of a preview edit when the final reply resolves a mention alias", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ text: "On it @Sentinel" }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 }, + cfg: { + channels: { discord: { mentionAliases: { Sentinel: "1485891428809707651" } } }, + }, + }); + + await runProcessDiscordMessage(ctx); + + // Discord only fires mention notifications on create, never on edits, so the + // streamed preview must be abandoned and the mention delivered fresh. + expect(editMessageDiscord).not.toHaveBeenCalled(); + expect(deliverDiscordReply).toHaveBeenCalledTimes(1); + }); + + it("delivers a fresh message instead of a preview edit for a literal user mention in the final reply", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ text: "On it <@1485891428809707651>" }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 }, + }); + + await runProcessDiscordMessage(ctx); + + expect(editMessageDiscord).not.toHaveBeenCalled(); + expect(deliverDiscordReply).toHaveBeenCalledTimes(1); + }); + + it("still finalizes via preview edit when an unaliased handle stays plain text", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ text: "On it @Sentinel" }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 }, + }); + + await runProcessDiscordMessage(ctx); + + expect(editMessageDiscord).toHaveBeenCalledTimes(1); + expect(deliverDiscordReply).not.toHaveBeenCalled(); + }); + + it("still finalizes via preview edit for broadcast mentions like @everyone", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ text: "heads up @everyone" }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 }, + }); + + await runProcessDiscordMessage(ctx); + + expect(editMessageDiscord).toHaveBeenCalledTimes(1); + expect(deliverDiscordReply).not.toHaveBeenCalled(); + }); + + it("still finalizes via preview edit when a targeted mention is mixed with @everyone", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ text: "heads up @Sentinel @everyone" }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 }, + cfg: { + channels: { discord: { mentionAliases: { Sentinel: "1485891428809707651" } } }, + }, + }); + + await runProcessDiscordMessage(ctx); + + // Mixed targeted + broadcast must not escalate into a create that pings @everyone. + expect(editMessageDiscord).toHaveBeenCalledTimes(1); + expect(deliverDiscordReply).not.toHaveBeenCalled(); + }); + it("accepts streaming=true alias for partial preview mode", async () => { await runSingleChunkFinalScenario({ streaming: true, maxLinesPerMessage: 5 }); expectSinglePreviewEdit(); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 2182df08f03..130ad4fd2a2 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -48,9 +48,14 @@ import { resolveSessionStoreEntry, resolveStorePath, } from "openclaw/plugin-sdk/session-store-runtime"; -import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; +import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { createDiscordRestClient } from "../client.js"; import { beginDiscordInboundEventDeliveryCorrelation } from "../inbound-event-delivery.js"; +import { + discordTextHasBroadcastMention, + discordTextHasTargetedMention, + rewriteDiscordKnownMentions, +} from "../mentions.js"; import { removeReactionDiscord } from "../send.js"; import { editMessageDiscord } from "../send.messages.js"; import { resolveDiscordTargetChannelId } from "../send.shared.js"; @@ -713,6 +718,17 @@ async function processDiscordMessageInner( ) { return undefined; } + // Discord pings only on create, not edits: send a targeted mention fresh, but keep mixed @everyone/@here in place so the create cannot escalate a broadcast. + const rewrittenFinal = rewriteDiscordKnownMentions(previewFinalText, { + accountId, + mentionAliases: resolveDiscordAccount({ cfg, accountId }).config.mentionAliases, + }); + if ( + discordTextHasTargetedMention(rewrittenFinal) && + !discordTextHasBroadcastMention(rewrittenFinal) + ) { + return undefined; + } return { content: previewFinalText, ...(finalPreviewFlags ? { flags: finalPreviewFlags } : {}),