From 8c4c12a6dd20bcfb5a976f64ae4addac2748194c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 03:50:57 +0100 Subject: [PATCH] fix(discord): add outbound mention aliases --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 6 +- docs/channels/discord.md | 24 ++++++ docs/gateway/config-channels.md | 1 + extensions/discord/src/config-ui-hints.ts | 4 + extensions/discord/src/mentions.test.ts | 26 +++++++ extensions/discord/src/mentions.ts | 75 +++++++++++++++++-- extensions/discord/src/send.outbound.ts | 2 + .../send.sends-basic-channel-messages.test.ts | 28 +++++++ .../discord/src/send.webhook-activity.test.ts | 27 +++++++ extensions/discord/src/send.webhook.ts | 1 + ...onfig.allowlist-requires-allowfrom.test.ts | 28 +++++++ src/config/types.discord.ts | 7 ++ src/config/zod-schema.providers-core.ts | 2 + 14 files changed, 221 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d21cbaa124e..d8e13cc3b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Discord: include Components v2 Text Display content from referenced replies and forwarded snapshots, so component-only messages still appear in reply context. Fixes #56228. Thanks @HollandDrive. - Discord: add configurable gateway READY timeouts for startup and runtime reconnects, so staggered multi-account setups can avoid false restart loops. Fixes #72273. Thanks @sergionsantos. - Discord: preserve native slash-command description localizations through command reconcile, so localized Discord descriptions no longer get overwritten by English defaults. Fixes #56580. Thanks @mhseo93. +- Discord: add configured outbound mention aliases so known `@Name` references can be rewritten to real Discord user mentions instead of relying only on the transient directory cache. Fixes #67587. Thanks @McoreD. - Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom. - Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 0c63fb9b77f..322e0e3af12 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -6666a7f876a31658b3e2f2a6564619cfaf2b282104fd6d7799656389431eb996 config-baseline.json +35224e7970e71225a51482432f1618ae3b54be9615956d8554a0e2df3d263bc8 config-baseline.json 80e6e8dce647aef2d1310de55a81d27de52cca47fc24bd7ad81b80f43a72b84c config-baseline.core.json -1cec599c3d27c258b9df3446baa547cb164e502afa9b30c052bba8737183f551 config-baseline.channel.json -1b2cb7fec6752245bc2a3da4a835f0bf9d31e6a468e777a5bdb91820398f44d0 config-baseline.plugin.json +eab8a85eefa2792fb8b98a07698e5ec31ff0b6f8af6222767e8049dcc5c4f529 config-baseline.channel.json +af71b84b2411d8ccabcc6e09de0ee41f8212ff9869a6677698b6e7e3afdfaa47 config-baseline.plugin.json diff --git a/docs/channels/discord.md b/docs/channels/discord.md index a182112aba9..e1d7cd38b8b 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -926,6 +926,30 @@ Default slash command settings: + + Use `mentionAliases` when agents need deterministic outbound mentions for known Discord users. Keys are handles without the leading `@`; values are Discord user IDs. Unknown handles, `@everyone`, `@here`, and mentions inside Markdown code spans are left unchanged. + +```json5 +{ + channels: { + discord: { + mentionAliases: { + Vladislava: "123456789012345678", + }, + accounts: { + ops: { + mentionAliases: { + OpsLead: "234567890123456789", + }, + }, + }, + }, + }, +} +``` + + + Presence updates are applied when you set a status or activity field, or when you enable auto presence. diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 11ef585e180..fe464449565 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -330,6 +330,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs. - Bot-authored messages are ignored by default. `allowBots: true` enables them; use `allowBots: "mentions"` to only accept bot messages that mention the bot (own messages still filtered). - `channels.discord.guilds..ignoreOtherMentions` (and channel overrides) drops messages that mention another user or role but not the bot (excluding @everyone/@here). +- `channels.discord.mentionAliases` maps stable outbound `@handle` text to Discord user IDs before sending, so known teammates can be mentioned deterministically even when the transient directory cache is empty. Per-account overrides live under `channels.discord.accounts..mentionAliases`. - `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars. - `channels.discord.threadBindings` controls Discord thread-bound routing: - `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and bound delivery/routing) diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index 4255a710ada..baaa5873646 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -233,6 +233,10 @@ export const discordChannelConfigUiHints = { label: "Discord Allow Bot Messages", help: 'Allow bot-authored messages to trigger Discord replies (default: false). Set "mentions" to only accept bot messages that mention the bot.', }, + mentionAliases: { + label: "Discord Mention Aliases", + help: "Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts..mentionAliases.", + }, token: { label: "Discord Bot Token", help: "Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.", diff --git a/extensions/discord/src/mentions.test.ts b/extensions/discord/src/mentions.test.ts index cc457786864..c6c75b75b0c 100644 --- a/extensions/discord/src/mentions.test.ts +++ b/extensions/discord/src/mentions.test.ts @@ -44,6 +44,32 @@ describe("rewriteDiscordKnownMentions", () => { expect(rewritten).toBe("ping <@123456789> and <@123456789>"); }); + it("rewrites configured mention aliases before the cache", () => { + rememberDiscordDirectoryUser({ + accountId: "default", + userId: "111111111", + handles: ["vladislava"], + }); + const rewritten = rewriteDiscordKnownMentions("ping @Vladislava and @BuildBot#1234", { + accountId: "default", + mentionAliases: { + BuildBot: "222222222", + Vladislava: "333333333", + }, + }); + expect(rewritten).toBe("ping <@333333333> and <@222222222>"); + }); + + it("supports configured aliases with a leading @ key", () => { + const rewritten = rewriteDiscordKnownMentions("ping @OpsLead", { + accountId: "default", + mentionAliases: { + "@opslead": "444444444", + }, + }); + expect(rewritten).toBe("ping <@444444444>"); + }); + it("preserves unknown mentions and reserved mentions", () => { rememberDiscordDirectoryUser({ accountId: "default", diff --git a/extensions/discord/src/mentions.ts b/extensions/discord/src/mentions.ts index 81c8345b92a..3f25bbaf004 100644 --- a/extensions/discord/src/mentions.ts +++ b/extensions/discord/src/mentions.ts @@ -5,9 +5,12 @@ import { } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordDirectoryUserId } from "./directory-cache.js"; +type DiscordMentionAliasesConfig = Record; + 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}$/; function normalizeSnowflake(value: string | number | bigint): string | null { const text = normalizeOptionalStringifiedId(value) ?? ""; @@ -43,7 +46,58 @@ export function formatMention(params: { return `<#${target.id}>`; } -function rewritePlainTextMentions(text: string, accountId?: string | null): string { +function normalizeHandleKey(raw: string): string | null { + let handle = normalizeOptionalString(raw) ?? ""; + if (!handle) { + return null; + } + if (handle.startsWith("@")) { + handle = normalizeOptionalString(handle.slice(1)) ?? ""; + } + if (!handle || /\s/.test(handle)) { + return null; + } + return normalizeLowercaseStringOrEmpty(handle); +} + +function resolveConfiguredMentionAlias( + handle: string, + mentionAliases?: DiscordMentionAliasesConfig | null, +): string | undefined { + const key = normalizeHandleKey(handle); + if (!key || !mentionAliases) { + return undefined; + } + const withoutDiscriminator = key.replace(DISCORD_DISCRIMINATOR_SUFFIX, ""); + for (const [rawAlias, rawUserId] of Object.entries(mentionAliases)) { + const alias = normalizeHandleKey(rawAlias); + if (!alias) { + continue; + } + const aliasWithoutDiscriminator = alias.replace(DISCORD_DISCRIMINATOR_SUFFIX, ""); + if ( + alias === key || + (withoutDiscriminator && withoutDiscriminator !== key && alias === withoutDiscriminator) || + (aliasWithoutDiscriminator && + aliasWithoutDiscriminator !== alias && + aliasWithoutDiscriminator === key) + ) { + const userId = normalizeSnowflake(rawUserId); + if (userId) { + return userId; + } + } + } + return undefined; +} + +function rewritePlainTextMentions( + text: string, + params: { + accountId?: string | null; + mentionAliases?: DiscordMentionAliasesConfig | null; + }, +): string { if (!text.includes("@")) { return text; } @@ -56,10 +110,12 @@ function rewritePlainTextMentions(text: string, accountId?: string | null): stri if (DISCORD_RESERVED_MENTIONS.has(lookup)) { return match; } - const userId = resolveDiscordDirectoryUserId({ - accountId, - handle, - }); + const userId = + resolveConfiguredMentionAlias(handle, params.mentionAliases) ?? + resolveDiscordDirectoryUserId({ + accountId: params.accountId, + handle, + }); if (!userId) { return match; } @@ -69,7 +125,10 @@ function rewritePlainTextMentions(text: string, accountId?: string | null): stri export function rewriteDiscordKnownMentions( text: string, - params: { accountId?: string | null }, + params: { + accountId?: string | null; + mentionAliases?: DiscordMentionAliasesConfig | null; + }, ): string { if (!text.includes("@")) { return text; @@ -79,10 +138,10 @@ export function rewriteDiscordKnownMentions( MARKDOWN_CODE_SEGMENT_PATTERN.lastIndex = 0; for (const match of text.matchAll(MARKDOWN_CODE_SEGMENT_PATTERN)) { const matchIndex = match.index ?? 0; - rewritten += rewritePlainTextMentions(text.slice(offset, matchIndex), params.accountId); + rewritten += rewritePlainTextMentions(text.slice(offset, matchIndex), params); rewritten += match[0]; offset = matchIndex + match[0].length; } - rewritten += rewritePlainTextMentions(text.slice(offset), params.accountId); + rewritten += rewritePlainTextMentions(text.slice(offset), params); return rewritten; } diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index cd0b42b7962..9ed92d9fb23 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -155,6 +155,7 @@ export async function sendMessageDiscord( const textWithTables = convertMarkdownTables(text ?? "", effectiveTableMode); const textWithMentions = rewriteDiscordKnownMentions(textWithTables, { accountId: accountInfo.accountId, + mentionAliases: accountInfo.config.mentionAliases, }); const { token, rest, request } = createDiscordClient({ ...opts, cfg }); const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId); @@ -406,6 +407,7 @@ async function resolveDiscordStructuredSendContext( const rewrittenContent = content ? rewriteDiscordKnownMentions(content, { accountId: accountInfo.accountId, + mentionAliases: accountInfo.config.mentionAliases, }) : undefined; return { rest, request, channelId, rewrittenContent }; diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index 91d6865ed9f..e776b8cd586 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -170,6 +170,34 @@ describe("sendMessageDiscord", () => { ); }); + it("rewrites configured @username aliases to id-based mentions", async () => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ + id: "msg1", + channel_id: "789", + }); + await sendMessageDiscord("channel:789", "ping @OpsLead", { + rest, + token: "t", + cfg: { + channels: { + discord: { + token: "t", + mentionAliases: { + opslead: "123456789012345678", + }, + }, + }, + } as never, + accountId: "default", + }); + expect(postMock).toHaveBeenCalledWith( + Routes.channelMessages("789"), + expect.objectContaining({ body: { content: "ping <@123456789012345678>" } }), + ); + }); + it("uses configured defaultAccount for cached mention rewriting when accountId is omitted", async () => { rememberDiscordDirectoryUser({ accountId: "work", diff --git a/extensions/discord/src/send.webhook-activity.test.ts b/extensions/discord/src/send.webhook-activity.test.ts index 990c0eaaf87..233ea676c05 100644 --- a/extensions/discord/src/send.webhook-activity.test.ts +++ b/extensions/discord/src/send.webhook-activity.test.ts @@ -75,4 +75,31 @@ describe("sendWebhookMessageDiscord activity", () => { }); expect(loadConfigMock).not.toHaveBeenCalled(); }); + + it("rewrites configured mention aliases for webhook sends", async () => { + const cfg = { + channels: { + discord: { + token: "resolved-token", + mentionAliases: { + opslead: "123456789012345678", + }, + }, + }, + }; + await sendWebhookMessageDiscord("hello @OpsLead", { + cfg, + webhookId: "wh-1", + webhookToken: "tok-1", + accountId: "runtime", + threadId: "thread-1", + }); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('"content":"hello <@123456789012345678>"'), + }), + ); + }); }); diff --git a/extensions/discord/src/send.webhook.ts b/extensions/discord/src/send.webhook.ts index bdd9237b9b7..829e7790c67 100644 --- a/extensions/discord/src/send.webhook.ts +++ b/extensions/discord/src/send.webhook.ts @@ -86,6 +86,7 @@ export async function sendWebhookMessageDiscord( }); const rewrittenText = rewriteDiscordKnownMentions(text, { accountId: account.accountId, + mentionAliases: account.config.mentionAliases, }); const response = await (proxyFetch ?? fetch)( diff --git a/src/config/config.allowlist-requires-allowfrom.test.ts b/src/config/config.allowlist-requires-allowfrom.test.ts index 7065004554a..479d9dab58e 100644 --- a/src/config/config.allowlist-requires-allowfrom.test.ts +++ b/src/config/config.allowlist-requires-allowfrom.test.ts @@ -137,3 +137,31 @@ describe('account dmPolicy="allowlist" uses inherited allowFrom', () => { ); }); }); + +describe("Discord mentionAliases schema", () => { + it("accepts stable outbound mention aliases on top-level and account config", () => { + expect( + DiscordConfigSchema.safeParse({ + mentionAliases: { + opslead: "123456789012345678", + }, + accounts: { + work: { + mentionAliases: { + vladislava: "234567890123456789", + }, + }, + }, + }).success, + ).toBe(true); + }); + + it("rejects non-snowflake mention alias targets", () => { + const result = DiscordConfigSchema.safeParse({ + mentionAliases: { + opslead: "not-a-user-id", + }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index d70cf8fb5b0..40c8e8adb71 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -23,6 +23,8 @@ export type DiscordPluralKitConfig = { token?: string; }; +export type DiscordMentionAliasesConfig = Record; + export type DiscordDmConfig = { /** If false, ignore all incoming Discord DMs. Default: true. */ enabled?: boolean; @@ -262,6 +264,11 @@ export type DiscordAccountConfig = { * Default behavior is ID-only matching. */ dangerouslyAllowNameMatching?: boolean; + /** + * Deterministic outbound @handle rewrites for known Discord users. + * Keys are handles without the leading @; values are Discord user IDs. + */ + mentionAliases?: DiscordMentionAliasesConfig; /** * Controls how guild channel messages are handled: * - "open": guild channels bypass allowlists; mention-gating applies diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 09fb4041fde..934a34e2371 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -59,6 +59,7 @@ const DiscordIdSchema = z }) .pipe(z.string()); const DiscordIdListSchema = z.array(DiscordIdSchema); +const DiscordSnowflakeStringSchema = z.string().regex(/^\d+$/, "Discord user ID must be numeric"); const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]); const TelegramIdListSchema = z.array(z.union([z.string(), z.number()])); @@ -536,6 +537,7 @@ export const DiscordAccountSchema = z gatewayRuntimeReadyTimeoutMs: z.number().int().positive().max(120_000).optional(), allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(), dangerouslyAllowNameMatching: z.boolean().optional(), + mentionAliases: z.record(z.string(), DiscordSnowflakeStringSchema).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), contextVisibility: ContextVisibilityModeSchema.optional(), historyLimit: z.number().int().min(0).optional(),