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(),