fix(discord): add outbound mention aliases

This commit is contained in:
Peter Steinberger
2026-05-02 03:50:57 +01:00
parent ec2d0772f1
commit 8c4c12a6dd
14 changed files with 221 additions and 11 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -926,6 +926,30 @@ Default slash command settings:
</Accordion>
<Accordion title="Outbound mention aliases">
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",
},
},
},
},
},
}
```
</Accordion>
<Accordion title="Presence configuration">
Presence updates are applied when you set a status or activity field, or when you enable auto presence.

View File

@@ -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.<id>.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.<accountId>.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)

View File

@@ -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.<id>.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.",

View File

@@ -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",

View File

@@ -5,9 +5,12 @@ import {
} from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordDirectoryUserId } from "./directory-cache.js";
type DiscordMentionAliasesConfig = Record<string, string>;
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;
}

View File

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

View File

@@ -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",

View File

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

View File

@@ -86,6 +86,7 @@ export async function sendWebhookMessageDiscord(
});
const rewrittenText = rewriteDiscordKnownMentions(text, {
accountId: account.accountId,
mentionAliases: account.config.mentionAliases,
});
const response = await (proxyFetch ?? fetch)(

View File

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

View File

@@ -23,6 +23,8 @@ export type DiscordPluralKitConfig = {
token?: string;
};
export type DiscordMentionAliasesConfig = Record<string, string>;
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

View File

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