mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-05 15:12:55 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user