fix: discord mention handling (#33224) (thanks @thewilloftheshadow) (#33224)

This commit is contained in:
Shadow
2026-03-03 10:32:22 -06:00
committed by GitHub
parent a3112d6c5f
commit d493861c16
18 changed files with 681 additions and 17 deletions

View File

@@ -31,6 +31,11 @@ export type DiscordDmConfig = {
export type DiscordGuildChannelConfig = {
allow?: boolean;
requireMention?: boolean;
/**
* If true, drop messages that mention another user/role but not this one (not @everyone/@here).
* Default: false.
*/
ignoreOtherMentions?: boolean;
/** Optional tool policy overrides for this channel. */
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
@@ -53,6 +58,11 @@ export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist
export type DiscordGuildEntry = {
slug?: string;
requireMention?: boolean;
/**
* If true, drop messages that mention another user/role but not this one (not @everyone/@here).
* Default: false.
*/
ignoreOtherMentions?: boolean;
/** Optional tool policy overrides for this guild (used when channel override is missing). */
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;

View File

@@ -345,6 +345,7 @@ export const DiscordGuildChannelSchema = z
.object({
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
ignoreOtherMentions: z.boolean().optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
skills: z.array(z.string()).optional(),
@@ -361,6 +362,7 @@ export const DiscordGuildSchema = z
.object({
slug: z.string().optional(),
requireMention: z.boolean().optional(),
ignoreOtherMentions: z.boolean().optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),

View File

@@ -0,0 +1,111 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js";
const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000;
const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/;
const DIRECTORY_HANDLE_CACHE = new Map<string, Map<string, string>>();
function normalizeAccountCacheKey(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId ?? DEFAULT_ACCOUNT_ID);
return normalized || DEFAULT_ACCOUNT_ID;
}
function normalizeSnowflake(value: string | number | bigint): string | null {
const text = String(value ?? "").trim();
if (!/^\d+$/.test(text)) {
return null;
}
return text;
}
function normalizeHandleKey(raw: string): string | null {
let handle = raw.trim();
if (!handle) {
return null;
}
if (handle.startsWith("@")) {
handle = handle.slice(1).trim();
}
if (!handle || /\s/.test(handle)) {
return null;
}
return handle.toLowerCase();
}
function ensureAccountCache(accountId?: string | null): Map<string, string> {
const cacheKey = normalizeAccountCacheKey(accountId);
const existing = DIRECTORY_HANDLE_CACHE.get(cacheKey);
if (existing) {
return existing;
}
const created = new Map<string, string>();
DIRECTORY_HANDLE_CACHE.set(cacheKey, created);
return created;
}
function setCacheEntry(cache: Map<string, string>, key: string, userId: string): void {
if (cache.has(key)) {
cache.delete(key);
}
cache.set(key, userId);
if (cache.size <= DISCORD_DIRECTORY_CACHE_MAX_ENTRIES) {
return;
}
const oldest = cache.keys().next();
if (!oldest.done) {
cache.delete(oldest.value);
}
}
export function rememberDiscordDirectoryUser(params: {
accountId?: string | null;
userId: string | number | bigint;
handles: Array<string | null | undefined>;
}): void {
const userId = normalizeSnowflake(params.userId);
if (!userId) {
return;
}
const cache = ensureAccountCache(params.accountId);
for (const candidate of params.handles) {
if (typeof candidate !== "string") {
continue;
}
const handle = normalizeHandleKey(candidate);
if (!handle) {
continue;
}
setCacheEntry(cache, handle, userId);
const withoutDiscriminator = handle.replace(DISCORD_DISCRIMINATOR_SUFFIX, "");
if (withoutDiscriminator && withoutDiscriminator !== handle) {
setCacheEntry(cache, withoutDiscriminator, userId);
}
}
}
export function resolveDiscordDirectoryUserId(params: {
accountId?: string | null;
handle: string;
}): string | undefined {
const cache = DIRECTORY_HANDLE_CACHE.get(normalizeAccountCacheKey(params.accountId));
if (!cache) {
return undefined;
}
const handle = normalizeHandleKey(params.handle);
if (!handle) {
return undefined;
}
const direct = cache.get(handle);
if (direct) {
return direct;
}
const withoutDiscriminator = handle.replace(DISCORD_DISCRIMINATOR_SUFFIX, "");
if (!withoutDiscriminator || withoutDiscriminator === handle) {
return undefined;
}
return cache.get(withoutDiscriminator);
}
export function __resetDiscordDirectoryCacheForTest(): void {
DIRECTORY_HANDLE_CACHE.clear();
}

View File

@@ -2,6 +2,7 @@ import type { DirectoryConfigParams } from "../channels/plugins/directory-config
import type { ChannelDirectoryEntry } from "../channels/plugins/types.js";
import { resolveDiscordAccount } from "./accounts.js";
import { fetchDiscord } from "./api.js";
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import { normalizeDiscordToken } from "./token.js";
@@ -102,6 +103,16 @@ export async function listDiscordDirectoryPeersLive(
if (!user?.id) {
continue;
}
rememberDiscordDirectoryUser({
accountId: params.accountId,
userId: user.id,
handles: [
user.username,
user.global_name,
member.nick,
user.username ? `@${user.username}` : null,
],
});
const name = member.nick?.trim() || user.global_name?.trim() || user.username?.trim();
rows.push({
kind: "user",

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
__resetDiscordDirectoryCacheForTest,
rememberDiscordDirectoryUser,
} from "./directory-cache.js";
import { formatMention, rewriteDiscordKnownMentions } from "./mentions.js";
describe("formatMention", () => {
it("formats user mentions from ids", () => {
expect(formatMention({ userId: "123456789" })).toBe("<@123456789>");
});
it("formats role mentions from ids", () => {
expect(formatMention({ roleId: "987654321" })).toBe("<@&987654321>");
});
it("formats channel mentions from ids", () => {
expect(formatMention({ channelId: "777555333" })).toBe("<#777555333>");
});
it("throws when no mention id is provided", () => {
expect(() => formatMention({})).toThrow(/exactly one/i);
});
it("throws when more than one mention id is provided", () => {
expect(() => formatMention({ userId: "1", roleId: "2" })).toThrow(/exactly one/i);
});
});
describe("rewriteDiscordKnownMentions", () => {
beforeEach(() => {
__resetDiscordDirectoryCacheForTest();
});
it("rewrites @name mentions when a cached user id exists", () => {
rememberDiscordDirectoryUser({
accountId: "default",
userId: "123456789",
handles: ["Alice", "@alice_user", "alice#1234"],
});
const rewritten = rewriteDiscordKnownMentions("ping @Alice and @alice_user", {
accountId: "default",
});
expect(rewritten).toBe("ping <@123456789> and <@123456789>");
});
it("preserves unknown mentions and reserved mentions", () => {
rememberDiscordDirectoryUser({
accountId: "default",
userId: "123456789",
handles: ["alice"],
});
const rewritten = rewriteDiscordKnownMentions("hello @unknown @everyone @here", {
accountId: "default",
});
expect(rewritten).toBe("hello @unknown @everyone @here");
});
it("does not rewrite mentions inside markdown code spans", () => {
rememberDiscordDirectoryUser({
accountId: "default",
userId: "123456789",
handles: ["alice"],
});
const rewritten = rewriteDiscordKnownMentions(
"inline `@alice` fence ```\n@alice\n``` text @alice",
{
accountId: "default",
},
);
expect(rewritten).toBe("inline `@alice` fence ```\n@alice\n``` text <@123456789>");
});
it("is account-scoped", () => {
rememberDiscordDirectoryUser({
accountId: "ops",
userId: "999888777",
handles: ["alice"],
});
const defaultRewrite = rewriteDiscordKnownMentions("@alice", { accountId: "default" });
const opsRewrite = rewriteDiscordKnownMentions("@alice", { accountId: "ops" });
expect(defaultRewrite).toBe("@alice");
expect(opsRewrite).toBe("<@999888777>");
});
});

83
src/discord/mentions.ts Normal file
View File

@@ -0,0 +1,83 @@
import { resolveDiscordDirectoryUserId } from "./directory-cache.js";
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"]);
function normalizeSnowflake(value: string | number | bigint): string | null {
const text = String(value ?? "").trim();
if (!/^\d+$/.test(text)) {
return null;
}
return text;
}
export function formatMention(params: {
userId?: string | number | bigint | null;
roleId?: string | number | bigint | null;
channelId?: string | number | bigint | null;
}): string {
const userId = params.userId == null ? null : normalizeSnowflake(params.userId);
const roleId = params.roleId == null ? null : normalizeSnowflake(params.roleId);
const channelId = params.channelId == null ? null : normalizeSnowflake(params.channelId);
const values = [
userId ? { kind: "user" as const, id: userId } : null,
roleId ? { kind: "role" as const, id: roleId } : null,
channelId ? { kind: "channel" as const, id: channelId } : null,
].filter((entry): entry is { kind: "user" | "role" | "channel"; id: string } => Boolean(entry));
if (values.length !== 1) {
throw new Error("formatMention requires exactly one of userId, roleId, or channelId");
}
const target = values[0];
if (target.kind === "user") {
return `<@${target.id}>`;
}
if (target.kind === "role") {
return `<@&${target.id}>`;
}
return `<#${target.id}>`;
}
function rewritePlainTextMentions(text: string, accountId?: string | null): string {
if (!text.includes("@")) {
return text;
}
return text.replace(MENTION_CANDIDATE_PATTERN, (match, prefix, rawHandle) => {
const handle = String(rawHandle ?? "").trim();
if (!handle) {
return match;
}
const lookup = handle.toLowerCase();
if (DISCORD_RESERVED_MENTIONS.has(lookup)) {
return match;
}
const userId = resolveDiscordDirectoryUserId({
accountId,
handle,
});
if (!userId) {
return match;
}
return `${String(prefix ?? "")}${formatMention({ userId })}`;
});
}
export function rewriteDiscordKnownMentions(
text: string,
params: { accountId?: string | null },
): string {
if (!text.includes("@")) {
return text;
}
let rewritten = "";
let offset = 0;
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 += match[0];
offset = matchIndex + match[0].length;
}
rewritten += rewritePlainTextMentions(text.slice(offset), params.accountId);
return rewritten;
}

View File

@@ -22,6 +22,7 @@ export type DiscordGuildEntryResolved = {
id?: string;
slug?: string;
requireMention?: boolean;
ignoreOtherMentions?: boolean;
reactionNotifications?: "off" | "own" | "all" | "allowlist";
users?: string[];
roles?: string[];
@@ -30,6 +31,7 @@ export type DiscordGuildEntryResolved = {
{
allow?: boolean;
requireMention?: boolean;
ignoreOtherMentions?: boolean;
skills?: string[];
enabled?: boolean;
users?: string[];
@@ -44,6 +46,7 @@ export type DiscordGuildEntryResolved = {
export type DiscordChannelConfigResolved = {
allowed: boolean;
requireMention?: boolean;
ignoreOtherMentions?: boolean;
skills?: string[];
enabled?: boolean;
users?: string[];
@@ -389,6 +392,7 @@ function resolveDiscordChannelConfigEntry(
const resolved: DiscordChannelConfigResolved = {
allowed: entry.allow !== false,
requireMention: entry.requireMention,
ignoreOtherMentions: entry.ignoreOtherMentions,
skills: entry.skills,
enabled: entry.enabled,
users: entry.users,

View File

@@ -354,6 +354,238 @@ describe("preflightDiscordMessage", () => {
expect(result?.shouldRequireMention).toBe(false);
});
it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => {
const channelId = "channel-other-mention-1";
const guildId = "guild-other-mention-1";
const client = {
fetchChannel: async (id: string) => {
if (id === channelId) {
return {
id: channelId,
type: ChannelType.GuildText,
name: "general",
};
}
return null;
},
} as unknown as import("@buape/carbon").Client;
const message = {
id: "m-other-mention-1",
content: "hello <@999>",
timestamp: new Date().toISOString(),
channelId,
attachments: [],
mentionedUsers: [{ id: "999" }],
mentionedRoles: [],
mentionedEveryone: false,
author: {
id: "user-1",
bot: false,
username: "Alice",
},
} as unknown as import("@buape/carbon").Message;
const result = await preflightDiscordMessage({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {} as NonNullable<
import("../../config/config.js").OpenClawConfig["channels"]
>["discord"],
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
botUserId: "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: createNoopThreadBindingManager("default"),
guildEntries: {
[guildId]: {
requireMention: false,
ignoreOtherMentions: true,
},
},
data: {
channel_id: channelId,
guild_id: guildId,
guild: {
id: guildId,
name: "Guild One",
},
author: message.author,
message,
} as unknown as import("./listeners.js").DiscordMessageEvent,
client,
});
expect(result).toBeNull();
});
it("does not drop @everyone messages when ignoreOtherMentions=true", async () => {
const channelId = "channel-other-mention-everyone";
const guildId = "guild-other-mention-everyone";
const client = {
fetchChannel: async (id: string) => {
if (id === channelId) {
return {
id: channelId,
type: ChannelType.GuildText,
name: "general",
};
}
return null;
},
} as unknown as import("@buape/carbon").Client;
const message = {
id: "m-other-mention-everyone",
content: "@everyone heads up",
timestamp: new Date().toISOString(),
channelId,
attachments: [],
mentionedUsers: [],
mentionedRoles: [],
mentionedEveryone: true,
author: {
id: "user-1",
bot: false,
username: "Alice",
},
} as unknown as import("@buape/carbon").Message;
const result = await preflightDiscordMessage({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {} as NonNullable<
import("../../config/config.js").OpenClawConfig["channels"]
>["discord"],
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
botUserId: "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: createNoopThreadBindingManager("default"),
guildEntries: {
[guildId]: {
requireMention: false,
ignoreOtherMentions: true,
},
},
data: {
channel_id: channelId,
guild_id: guildId,
guild: {
id: guildId,
name: "Guild One",
},
author: message.author,
message,
} as unknown as import("./listeners.js").DiscordMessageEvent,
client,
});
expect(result).not.toBeNull();
expect(result?.hasAnyMention).toBe(true);
});
it("ignores bot-sent @everyone mentions for detection", async () => {
const channelId = "channel-everyone-1";
const guildId = "guild-everyone-1";
const client = {
fetchChannel: async (id: string) => {
if (id === channelId) {
return {
id: channelId,
type: ChannelType.GuildText,
name: "general",
};
}
return null;
},
} as unknown as import("@buape/carbon").Client;
const message = {
id: "m-everyone-1",
content: "@everyone heads up",
timestamp: new Date().toISOString(),
channelId,
attachments: [],
mentionedUsers: [],
mentionedRoles: [],
mentionedEveryone: true,
author: {
id: "relay-bot-1",
bot: true,
username: "Relay",
},
} as unknown as import("@buape/carbon").Message;
const result = await preflightDiscordMessage({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {
allowBots: true,
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
botUserId: "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: createNoopThreadBindingManager("default"),
guildEntries: {
[guildId]: {
requireMention: false,
},
},
data: {
channel_id: channelId,
guild_id: guildId,
guild: {
id: guildId,
name: "Guild One",
},
author: message.author,
message,
} as unknown as import("./listeners.js").DiscordMessageEvent,
client,
});
expect(result).not.toBeNull();
expect(result?.hasAnyMention).toBe(false);
});
it("uses attachment content_type for guild audio preflight mention detection", async () => {
transcribeFirstAudioMock.mockResolvedValue("hey openclaw");

View File

@@ -358,9 +358,13 @@ export async function preflightDiscordMessage(
);
const hasAnyMention = Boolean(
!isDirectMessage &&
(message.mentionedEveryone ||
(message.mentionedUsers?.length ?? 0) > 0 ||
(message.mentionedRoles?.length ?? 0) > 0),
((message.mentionedUsers?.length ?? 0) > 0 ||
(message.mentionedRoles?.length ?? 0) > 0 ||
(message.mentionedEveryone && (!author.bot || sender.isPluralKit))),
);
const hasUserOrRoleMention = Boolean(
!isDirectMessage &&
((message.mentionedUsers?.length ?? 0) > 0 || (message.mentionedRoles?.length ?? 0) > 0),
);
if (
@@ -429,7 +433,7 @@ export async function preflightDiscordMessage(
const channelMatchMeta = formatAllowlistMatchMeta(channelConfig);
if (shouldLogVerbose()) {
const channelConfigSummary = channelConfig
? `allowed=${channelConfig.allowed} enabled=${channelConfig.enabled ?? "unset"} requireMention=${channelConfig.requireMention ?? "unset"} matchKey=${channelConfig.matchKey ?? "none"} matchSource=${channelConfig.matchSource ?? "none"} users=${channelConfig.users?.length ?? 0} roles=${channelConfig.roles?.length ?? 0} skills=${channelConfig.skills?.length ?? 0}`
? `allowed=${channelConfig.allowed} enabled=${channelConfig.enabled ?? "unset"} requireMention=${channelConfig.requireMention ?? "unset"} ignoreOtherMentions=${channelConfig.ignoreOtherMentions ?? "unset"} matchKey=${channelConfig.matchKey ?? "none"} matchSource=${channelConfig.matchSource ?? "none"} users=${channelConfig.users?.length ?? 0} roles=${channelConfig.roles?.length ?? 0} skills=${channelConfig.skills?.length ?? 0}`
: "none";
logDebug(
`[discord-preflight] channelConfig=${channelConfigSummary} channelMatchMeta=${channelMatchMeta} channelId=${messageChannelId}`,
@@ -644,6 +648,28 @@ export async function preflightDiscordMessage(
}
}
const ignoreOtherMentions =
channelConfig?.ignoreOtherMentions ?? guildInfo?.ignoreOtherMentions ?? false;
if (
isGuildMessage &&
ignoreOtherMentions &&
hasUserOrRoleMention &&
!wasMentioned &&
!implicitMention
) {
logDebug(`[discord-preflight] drop: other-mention`);
logVerbose(
`discord: drop guild message (another user/role mentioned, ignoreOtherMentions=true, botId=${botId})`,
);
recordPendingHistoryEntryIfEnabled({
historyMap: params.guildHistories,
historyKey: messageChannelId,
limit: params.historyLimit,
entry: historyEntry ?? null,
});
return null;
}
if (isGuildMessage && hasAccessRestrictions && !memberAllowed) {
logDebug(`[discord-preflight] drop: member not allowed`);
logVerbose(`Blocked discord guild sender ${sender.id} (not in users/roles allowlist)`);

View File

@@ -510,6 +510,29 @@ describe("resolveDiscordMessageText", () => {
expect(text).toContain("forwarded hello");
});
it("resolves user mentions in content", () => {
const text = resolveDiscordMessageText(
asMessage({
content: "Hello <@123> and <@456>!",
mentionedUsers: [
{ id: "123", username: "alice", globalName: "Alice Wonderland", discriminator: "0" },
{ id: "456", username: "bob", discriminator: "0" },
],
}),
);
expect(text).toBe("Hello @Alice Wonderland and @bob!");
});
it("leaves content unchanged if no mentions present", () => {
const text = resolveDiscordMessageText(
asMessage({
content: "Hello world",
mentionedUsers: [],
}),
);
expect(text).toBe("Hello world");
});
it("uses sticker placeholders when content is empty", () => {
const text = resolveDiscordMessageText(
asMessage({

View File

@@ -457,7 +457,7 @@ export function resolveDiscordMessageText(
(message.embeds?.[0] as { title?: string | null; description?: string | null } | undefined) ??
null,
);
const baseText =
const rawText =
message.content?.trim() ||
buildDiscordMediaPlaceholder({
attachments: message.attachments ?? undefined,
@@ -466,6 +466,7 @@ export function resolveDiscordMessageText(
embedText ||
options?.fallbackText?.trim() ||
"";
const baseText = resolveDiscordMentions(rawText, message);
if (!options?.includeForwarded) {
return baseText;
}
@@ -479,6 +480,22 @@ export function resolveDiscordMessageText(
return `${baseText}\n${forwardedText}`;
}
function resolveDiscordMentions(text: string, message: Message): string {
if (!text.includes("<")) {
return text;
}
const mentions = message.mentionedUsers ?? [];
if (!Array.isArray(mentions) || mentions.length === 0) {
return text;
}
let out = text;
for (const user of mentions) {
const label = user.globalName || user.username;
out = out.replace(new RegExp(`<@!?${user.id}>`, "g"), `@${label}`);
}
return out;
}
function resolveDiscordForwardedMessagesText(message: Message): string {
const snapshots = resolveDiscordMessageSnapshots(message);
if (snapshots.length === 0) {

View File

@@ -16,6 +16,7 @@ import { unlinkIfExists } from "../media/temp-files.js";
import type { PollInput } from "../polls.js";
import { loadWebMediaRaw } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { rewriteDiscordKnownMentions } from "./mentions.js";
import {
buildDiscordMessagePayload,
buildDiscordSendError,
@@ -144,6 +145,9 @@ export async function sendMessageDiscord(
});
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
const textWithMentions = rewriteDiscordKnownMentions(textWithTables, {
accountId: accountInfo.accountId,
});
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
@@ -153,7 +157,7 @@ export async function sendMessageDiscord(
if (isForumLikeType(channelType)) {
const threadName = deriveForumThreadName(textWithTables);
const chunks = buildDiscordTextChunks(textWithTables, {
const chunks = buildDiscordTextChunks(textWithMentions, {
maxLinesPerMessage: accountInfo.config.maxLinesPerMessage,
chunkMode,
});
@@ -263,7 +267,7 @@ export async function sendMessageDiscord(
result = await sendDiscordMedia(
rest,
channelId,
textWithTables,
textWithMentions,
opts.mediaUrl,
opts.mediaLocalRoots,
opts.replyTo,
@@ -278,7 +282,7 @@ export async function sendMessageDiscord(
result = await sendDiscordText(
rest,
channelId,
textWithTables,
textWithMentions,
opts.replyTo,
request,
accountInfo.config.maxLinesPerMessage,
@@ -342,6 +346,9 @@ export async function sendWebhookMessageDiscord(
throw new Error("Discord webhook id/token are required");
}
const rewrittenText = rewriteDiscordKnownMentions(text, {
accountId: opts.accountId,
});
const replyTo = typeof opts.replyTo === "string" ? opts.replyTo.trim() : "";
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
@@ -358,7 +365,7 @@ export async function sendWebhookMessageDiscord(
"content-type": "application/json",
},
body: JSON.stringify({
content: text,
content: rewrittenText,
username: opts.username?.trim() || undefined,
avatar_url: opts.avatarUrl?.trim() || undefined,
...(messageReference ? { message_reference: messageReference } : {}),
@@ -406,12 +413,17 @@ export async function sendStickerDiscord(
): Promise<DiscordSendResult> {
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
const content = opts.content?.trim();
const rewrittenContent = content
? rewriteDiscordKnownMentions(content, {
accountId: opts.accountId,
})
: undefined;
const stickers = normalizeStickerIds(stickerIds);
const res = (await request(
() =>
rest.post(Routes.channelMessages(channelId), {
body: {
content: content || undefined,
content: rewrittenContent || undefined,
sticker_ids: stickers,
},
}) as Promise<{ id: string; channel_id: string }>,
@@ -427,6 +439,11 @@ export async function sendPollDiscord(
): Promise<DiscordSendResult> {
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
const content = opts.content?.trim();
const rewrittenContent = content
? rewriteDiscordKnownMentions(content, {
accountId: opts.accountId,
})
: undefined;
if (poll.durationSeconds !== undefined) {
throw new Error("Discord polls do not support durationSeconds; use durationHours");
}
@@ -436,7 +453,7 @@ export async function sendPollDiscord(
() =>
rest.post(Routes.channelMessages(channelId), {
body: {
content: content || undefined,
content: rewrittenContent || undefined,
poll: payload,
...(flags ? { flags } : {}),
},

View File

@@ -1,5 +1,9 @@
import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetDiscordDirectoryCacheForTest,
rememberDiscordDirectoryUser,
} from "./directory-cache.js";
import {
deleteMessageDiscord,
editMessageDiscord,
@@ -62,6 +66,7 @@ describe("sendMessageDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetDiscordDirectoryCacheForTest();
});
it("sends basic channel messages", async () => {
@@ -83,6 +88,29 @@ describe("sendMessageDiscord", () => {
);
});
it("rewrites cached @username mentions to id-based mentions", async () => {
rememberDiscordDirectoryUser({
accountId: "default",
userId: "123456789012345678",
handles: ["Alice"],
});
const { rest, postMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
postMock.mockResolvedValue({
id: "msg1",
channel_id: "789",
});
await sendMessageDiscord("channel:789", "ping @Alice", {
rest,
token: "t",
accountId: "default",
});
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({ body: { content: "ping <@123456789012345678>" } }),
);
});
it("auto-creates a forum thread when target is a Forum channel", async () => {
const { rest, postMock, getMock } = makeDiscordRest();
// Channel type lookup returns a Forum channel.

View File

@@ -87,7 +87,6 @@ export async function parseAndResolveRecipient(
// First try to resolve using directory lookup (handles usernames)
const trimmed = raw.trim();
const parseOptions = {
defaultKind: "channel" as const,
ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
};

View File

@@ -7,6 +7,7 @@ import {
type MessagingTargetKind,
type MessagingTargetParseOptions,
} from "../channels/targets.js";
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
export type DiscordTargetKind = MessagingTargetKind;
@@ -99,6 +100,11 @@ export async function resolveDiscordTarget(
if (match && match.kind === "user") {
// Extract user ID from the directory entry (format: "user:<id>")
const userId = match.id.replace(/^user:/, "");
rememberDiscordDirectoryUser({
accountId: options.accountId,
userId,
handles: [trimmed, match.name, match.handle],
});
return buildMessagingTarget("user", userId, trimmed);
}
} catch {

View File

@@ -14,6 +14,7 @@ import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-
import type { OpenClawConfig } from "../../config/config.js";
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
import type { DiscordAccountConfig } from "../../config/types.js";
import { formatMention } from "../mentions.js";
import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordSlug,
@@ -139,7 +140,7 @@ async function authorizeVoiceCommand(
channelConfig?.allowed === false
) {
const channelId = channelOverride?.id ?? channel?.id;
const channelLabel = channelId ? `<#${channelId}>` : "This channel";
const channelLabel = channelId ? formatMention({ channelId }) : "This channel";
return {
ok: false,
message: `${channelLabel} is not allowlisted for voice commands.`,
@@ -352,7 +353,9 @@ export function createDiscordVoiceCommand(params: VoiceCommandContext): CommandW
await interaction.reply({ content: "No active voice sessions.", ephemeral: true });
return;
}
const lines = sessions.map((entry) => `• <#${entry.channelId}> (guild ${entry.guildId})`);
const lines = sessions.map(
(entry) => `${formatMention({ channelId: entry.channelId })} (guild ${entry.guildId})`,
);
await interaction.reply({ content: lines.join("\n"), ephemeral: true });
}
}

View File

@@ -36,6 +36,7 @@ import { resolveAgentRoute } from "../../routing/resolve-route.js";
import type { RuntimeEnv } from "../../runtime.js";
import { parseTtsDirectives } from "../../tts/tts-core.js";
import { resolveTtsConfig, textToSpeech, type ResolvedTtsConfig } from "../../tts/tts.js";
import { formatMention } from "../mentions.js";
import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js";
import { formatDiscordUserTag } from "../monitor/format.js";
@@ -378,7 +379,12 @@ export class DiscordVoiceManager {
const existing = this.sessions.get(guildId);
if (existing && existing.channelId === channelId) {
logVoiceVerbose(`join: already connected to guild ${guildId} channel ${channelId}`);
return { ok: true, message: `Already connected to <#${channelId}>.`, guildId, channelId };
return {
ok: true,
message: `Already connected to ${formatMention({ channelId })}.`,
guildId,
channelId,
};
}
if (existing) {
logVoiceVerbose(`join: replacing existing session for guild ${guildId}`);
@@ -518,7 +524,7 @@ export class DiscordVoiceManager {
this.sessions.set(guildId, entry);
return {
ok: true,
message: `Joined <#${channelId}>.`,
message: `Joined ${formatMention({ channelId })}.`,
guildId,
channelId,
};
@@ -539,7 +545,7 @@ export class DiscordVoiceManager {
logVoiceVerbose(`leave: disconnected from guild ${guildId} channel ${entry.channelId}`);
return {
ok: true,
message: `Left <#${entry.channelId}>.`,
message: `Left ${formatMention({ channelId: entry.channelId })}.`,
guildId,
channelId: entry.channelId,
};