Skip to content

Commit 0b43e4b

Browse files
fix: discord mention handling (#33224) (thanks @thewilloftheshadow)
1 parent 6593a57 commit 0b43e4b

18 files changed

+681
-17
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717
- Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
1818
- Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
1919
- Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.
20+
- Discord/mention handling: add id-based mention formatting + cached rewrites, resolve inbound mentions to display names, and add optional ignoreOtherMentions gating (excluding @everyone/@here). (#33224) Thanks @thewilloftheshadow.
2021
- Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf
2122
- macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://<peer>.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
2223
- iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky.

src/config/types.discord.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export type DiscordDmConfig = {
3131
export type DiscordGuildChannelConfig = {
3232
allow?: boolean;
3333
requireMention?: boolean;
34+
/**
35+
* If true, drop messages that mention another user/role but not this one (not @everyone/@here).
36+
* Default: false.
37+
*/
38+
ignoreOtherMentions?: boolean;
3439
/** Optional tool policy overrides for this channel. */
3540
tools?: GroupToolPolicyConfig;
3641
toolsBySender?: GroupToolPolicyBySenderConfig;
@@ -53,6 +58,11 @@ export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist
5358
export type DiscordGuildEntry = {
5459
slug?: string;
5560
requireMention?: boolean;
61+
/**
62+
* If true, drop messages that mention another user/role but not this one (not @everyone/@here).
63+
* Default: false.
64+
*/
65+
ignoreOtherMentions?: boolean;
5666
/** Optional tool policy overrides for this guild (used when channel override is missing). */
5767
tools?: GroupToolPolicyConfig;
5868
toolsBySender?: GroupToolPolicyBySenderConfig;

src/config/zod-schema.providers-core.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ export const DiscordGuildChannelSchema = z
345345
.object({
346346
allow: z.boolean().optional(),
347347
requireMention: z.boolean().optional(),
348+
ignoreOtherMentions: z.boolean().optional(),
348349
tools: ToolPolicySchema,
349350
toolsBySender: ToolPolicyBySenderSchema,
350351
skills: z.array(z.string()).optional(),
@@ -361,6 +362,7 @@ export const DiscordGuildSchema = z
361362
.object({
362363
slug: z.string().optional(),
363364
requireMention: z.boolean().optional(),
365+
ignoreOtherMentions: z.boolean().optional(),
364366
tools: ToolPolicySchema,
365367
toolsBySender: ToolPolicyBySenderSchema,
366368
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),

src/discord/directory-cache.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js";
2+
3+
const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000;
4+
const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/;
5+
6+
const DIRECTORY_HANDLE_CACHE = new Map<string, Map<string, string>>();
7+
8+
function normalizeAccountCacheKey(accountId?: string | null): string {
9+
const normalized = normalizeAccountId(accountId ?? DEFAULT_ACCOUNT_ID);
10+
return normalized || DEFAULT_ACCOUNT_ID;
11+
}
12+
13+
function normalizeSnowflake(value: string | number | bigint): string | null {
14+
const text = String(value ?? "").trim();
15+
if (!/^\d+$/.test(text)) {
16+
return null;
17+
}
18+
return text;
19+
}
20+
21+
function normalizeHandleKey(raw: string): string | null {
22+
let handle = raw.trim();
23+
if (!handle) {
24+
return null;
25+
}
26+
if (handle.startsWith("@")) {
27+
handle = handle.slice(1).trim();
28+
}
29+
if (!handle || /\s/.test(handle)) {
30+
return null;
31+
}
32+
return handle.toLowerCase();
33+
}
34+
35+
function ensureAccountCache(accountId?: string | null): Map<string, string> {
36+
const cacheKey = normalizeAccountCacheKey(accountId);
37+
const existing = DIRECTORY_HANDLE_CACHE.get(cacheKey);
38+
if (existing) {
39+
return existing;
40+
}
41+
const created = new Map<string, string>();
42+
DIRECTORY_HANDLE_CACHE.set(cacheKey, created);
43+
return created;
44+
}
45+
46+
function setCacheEntry(cache: Map<string, string>, key: string, userId: string): void {
47+
if (cache.has(key)) {
48+
cache.delete(key);
49+
}
50+
cache.set(key, userId);
51+
if (cache.size <= DISCORD_DIRECTORY_CACHE_MAX_ENTRIES) {
52+
return;
53+
}
54+
const oldest = cache.keys().next();
55+
if (!oldest.done) {
56+
cache.delete(oldest.value);
57+
}
58+
}
59+
60+
export function rememberDiscordDirectoryUser(params: {
61+
accountId?: string | null;
62+
userId: string | number | bigint;
63+
handles: Array<string | null | undefined>;
64+
}): void {
65+
const userId = normalizeSnowflake(params.userId);
66+
if (!userId) {
67+
return;
68+
}
69+
const cache = ensureAccountCache(params.accountId);
70+
for (const candidate of params.handles) {
71+
if (typeof candidate !== "string") {
72+
continue;
73+
}
74+
const handle = normalizeHandleKey(candidate);
75+
if (!handle) {
76+
continue;
77+
}
78+
setCacheEntry(cache, handle, userId);
79+
const withoutDiscriminator = handle.replace(DISCORD_DISCRIMINATOR_SUFFIX, "");
80+
if (withoutDiscriminator && withoutDiscriminator !== handle) {
81+
setCacheEntry(cache, withoutDiscriminator, userId);
82+
}
83+
}
84+
}
85+
86+
export function resolveDiscordDirectoryUserId(params: {
87+
accountId?: string | null;
88+
handle: string;
89+
}): string | undefined {
90+
const cache = DIRECTORY_HANDLE_CACHE.get(normalizeAccountCacheKey(params.accountId));
91+
if (!cache) {
92+
return undefined;
93+
}
94+
const handle = normalizeHandleKey(params.handle);
95+
if (!handle) {
96+
return undefined;
97+
}
98+
const direct = cache.get(handle);
99+
if (direct) {
100+
return direct;
101+
}
102+
const withoutDiscriminator = handle.replace(DISCORD_DISCRIMINATOR_SUFFIX, "");
103+
if (!withoutDiscriminator || withoutDiscriminator === handle) {
104+
return undefined;
105+
}
106+
return cache.get(withoutDiscriminator);
107+
}
108+
109+
export function __resetDiscordDirectoryCacheForTest(): void {
110+
DIRECTORY_HANDLE_CACHE.clear();
111+
}

src/discord/directory-live.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { DirectoryConfigParams } from "../channels/plugins/directory-config
22
import type { ChannelDirectoryEntry } from "../channels/plugins/types.js";
33
import { resolveDiscordAccount } from "./accounts.js";
44
import { fetchDiscord } from "./api.js";
5+
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
56
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
67
import { normalizeDiscordToken } from "./token.js";
78

@@ -102,6 +103,16 @@ export async function listDiscordDirectoryPeersLive(
102103
if (!user?.id) {
103104
continue;
104105
}
106+
rememberDiscordDirectoryUser({
107+
accountId: params.accountId,
108+
userId: user.id,
109+
handles: [
110+
user.username,
111+
user.global_name,
112+
member.nick,
113+
user.username ? `@${user.username}` : null,
114+
],
115+
});
105116
const name = member.nick?.trim() || user.global_name?.trim() || user.username?.trim();
106117
rows.push({
107118
kind: "user",

src/discord/mentions.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
__resetDiscordDirectoryCacheForTest,
4+
rememberDiscordDirectoryUser,
5+
} from "./directory-cache.js";
6+
import { formatMention, rewriteDiscordKnownMentions } from "./mentions.js";
7+
8+
describe("formatMention", () => {
9+
it("formats user mentions from ids", () => {
10+
expect(formatMention({ userId: "123456789" })).toBe("<@123456789>");
11+
});
12+
13+
it("formats role mentions from ids", () => {
14+
expect(formatMention({ roleId: "987654321" })).toBe("<@&987654321>");
15+
});
16+
17+
it("formats channel mentions from ids", () => {
18+
expect(formatMention({ channelId: "777555333" })).toBe("<#777555333>");
19+
});
20+
21+
it("throws when no mention id is provided", () => {
22+
expect(() => formatMention({})).toThrow(/exactly one/i);
23+
});
24+
25+
it("throws when more than one mention id is provided", () => {
26+
expect(() => formatMention({ userId: "1", roleId: "2" })).toThrow(/exactly one/i);
27+
});
28+
});
29+
30+
describe("rewriteDiscordKnownMentions", () => {
31+
beforeEach(() => {
32+
__resetDiscordDirectoryCacheForTest();
33+
});
34+
35+
it("rewrites @name mentions when a cached user id exists", () => {
36+
rememberDiscordDirectoryUser({
37+
accountId: "default",
38+
userId: "123456789",
39+
handles: ["Alice", "@alice_user", "alice#1234"],
40+
});
41+
const rewritten = rewriteDiscordKnownMentions("ping @Alice and @alice_user", {
42+
accountId: "default",
43+
});
44+
expect(rewritten).toBe("ping <@123456789> and <@123456789>");
45+
});
46+
47+
it("preserves unknown mentions and reserved mentions", () => {
48+
rememberDiscordDirectoryUser({
49+
accountId: "default",
50+
userId: "123456789",
51+
handles: ["alice"],
52+
});
53+
const rewritten = rewriteDiscordKnownMentions("hello @unknown @everyone @here", {
54+
accountId: "default",
55+
});
56+
expect(rewritten).toBe("hello @unknown @everyone @here");
57+
});
58+
59+
it("does not rewrite mentions inside markdown code spans", () => {
60+
rememberDiscordDirectoryUser({
61+
accountId: "default",
62+
userId: "123456789",
63+
handles: ["alice"],
64+
});
65+
const rewritten = rewriteDiscordKnownMentions(
66+
"inline `@alice` fence ```\n@alice\n``` text @alice",
67+
{
68+
accountId: "default",
69+
},
70+
);
71+
expect(rewritten).toBe("inline `@alice` fence ```\n@alice\n``` text <@123456789>");
72+
});
73+
74+
it("is account-scoped", () => {
75+
rememberDiscordDirectoryUser({
76+
accountId: "ops",
77+
userId: "999888777",
78+
handles: ["alice"],
79+
});
80+
const defaultRewrite = rewriteDiscordKnownMentions("@alice", { accountId: "default" });
81+
const opsRewrite = rewriteDiscordKnownMentions("@alice", { accountId: "ops" });
82+
expect(defaultRewrite).toBe("@alice");
83+
expect(opsRewrite).toBe("<@999888777>");
84+
});
85+
});

src/discord/mentions.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { resolveDiscordDirectoryUserId } from "./directory-cache.js";
2+
3+
const MARKDOWN_CODE_SEGMENT_PATTERN = /```[\s\S]*?```|`[^`\n]*`/g;
4+
const MENTION_CANDIDATE_PATTERN = /(^|[\s([{"'.,;:!?])@([a-z0-9_.-]{2,32}(?:#[0-9]{4})?)/gi;
5+
const DISCORD_RESERVED_MENTIONS = new Set(["everyone", "here"]);
6+
7+
function normalizeSnowflake(value: string | number | bigint): string | null {
8+
const text = String(value ?? "").trim();
9+
if (!/^\d+$/.test(text)) {
10+
return null;
11+
}
12+
return text;
13+
}
14+
15+
export function formatMention(params: {
16+
userId?: string | number | bigint | null;
17+
roleId?: string | number | bigint | null;
18+
channelId?: string | number | bigint | null;
19+
}): string {
20+
const userId = params.userId == null ? null : normalizeSnowflake(params.userId);
21+
const roleId = params.roleId == null ? null : normalizeSnowflake(params.roleId);
22+
const channelId = params.channelId == null ? null : normalizeSnowflake(params.channelId);
23+
const values = [
24+
userId ? { kind: "user" as const, id: userId } : null,
25+
roleId ? { kind: "role" as const, id: roleId } : null,
26+
channelId ? { kind: "channel" as const, id: channelId } : null,
27+
].filter((entry): entry is { kind: "user" | "role" | "channel"; id: string } => Boolean(entry));
28+
if (values.length !== 1) {
29+
throw new Error("formatMention requires exactly one of userId, roleId, or channelId");
30+
}
31+
const target = values[0];
32+
if (target.kind === "user") {
33+
return `<@${target.id}>`;
34+
}
35+
if (target.kind === "role") {
36+
return `<@&${target.id}>`;
37+
}
38+
return `<#${target.id}>`;
39+
}
40+
41+
function rewritePlainTextMentions(text: string, accountId?: string | null): string {
42+
if (!text.includes("@")) {
43+
return text;
44+
}
45+
return text.replace(MENTION_CANDIDATE_PATTERN, (match, prefix, rawHandle) => {
46+
const handle = String(rawHandle ?? "").trim();
47+
if (!handle) {
48+
return match;
49+
}
50+
const lookup = handle.toLowerCase();
51+
if (DISCORD_RESERVED_MENTIONS.has(lookup)) {
52+
return match;
53+
}
54+
const userId = resolveDiscordDirectoryUserId({
55+
accountId,
56+
handle,
57+
});
58+
if (!userId) {
59+
return match;
60+
}
61+
return `${String(prefix ?? "")}${formatMention({ userId })}`;
62+
});
63+
}
64+
65+
export function rewriteDiscordKnownMentions(
66+
text: string,
67+
params: { accountId?: string | null },
68+
): string {
69+
if (!text.includes("@")) {
70+
return text;
71+
}
72+
let rewritten = "";
73+
let offset = 0;
74+
MARKDOWN_CODE_SEGMENT_PATTERN.lastIndex = 0;
75+
for (const match of text.matchAll(MARKDOWN_CODE_SEGMENT_PATTERN)) {
76+
const matchIndex = match.index ?? 0;
77+
rewritten += rewritePlainTextMentions(text.slice(offset, matchIndex), params.accountId);
78+
rewritten += match[0];
79+
offset = matchIndex + match[0].length;
80+
}
81+
rewritten += rewritePlainTextMentions(text.slice(offset), params.accountId);
82+
return rewritten;
83+
}

src/discord/monitor/allow-list.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type DiscordGuildEntryResolved = {
2222
id?: string;
2323
slug?: string;
2424
requireMention?: boolean;
25+
ignoreOtherMentions?: boolean;
2526
reactionNotifications?: "off" | "own" | "all" | "allowlist";
2627
users?: string[];
2728
roles?: string[];
@@ -30,6 +31,7 @@ export type DiscordGuildEntryResolved = {
3031
{
3132
allow?: boolean;
3233
requireMention?: boolean;
34+
ignoreOtherMentions?: boolean;
3335
skills?: string[];
3436
enabled?: boolean;
3537
users?: string[];
@@ -44,6 +46,7 @@ export type DiscordGuildEntryResolved = {
4446
export type DiscordChannelConfigResolved = {
4547
allowed: boolean;
4648
requireMention?: boolean;
49+
ignoreOtherMentions?: boolean;
4750
skills?: string[];
4851
enabled?: boolean;
4952
users?: string[];
@@ -389,6 +392,7 @@ function resolveDiscordChannelConfigEntry(
389392
const resolved: DiscordChannelConfigResolved = {
390393
allowed: entry.allow !== false,
391394
requireMention: entry.requireMention,
395+
ignoreOtherMentions: entry.ignoreOtherMentions,
392396
skills: entry.skills,
393397
enabled: entry.enabled,
394398
users: entry.users,

0 commit comments

Comments
 (0)