mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(discord): add outbound mention aliases
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>"'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,7 @@ export async function sendWebhookMessageDiscord(
|
||||
});
|
||||
const rewrittenText = rewriteDiscordKnownMentions(text, {
|
||||
accountId: account.accountId,
|
||||
mentionAliases: account.config.mentionAliases,
|
||||
});
|
||||
|
||||
const response = await (proxyFetch ?? fetch)(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user