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.
This commit is contained in:
Chunyue Wang
2026-05-31 22:52:59 +08:00
committed by GitHub
parent 8f941ea0ac
commit a5d8f09fd4
4 changed files with 150 additions and 2 deletions

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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 } : {}),