diff --git a/CHANGELOG.md b/CHANGELOG.md index d59aa855221..0759ac07689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - 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. - 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. - 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. +- 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. - 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 - macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman. - 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. diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index cd0edbe05f4..2c0227ee863 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -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; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index de4cd838048..4ba06e4bd8e 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(), diff --git a/src/discord/directory-cache.ts b/src/discord/directory-cache.ts new file mode 100644 index 00000000000..4cb17865eae --- /dev/null +++ b/src/discord/directory-cache.ts @@ -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>(); + +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 { + const cacheKey = normalizeAccountCacheKey(accountId); + const existing = DIRECTORY_HANDLE_CACHE.get(cacheKey); + if (existing) { + return existing; + } + const created = new Map(); + DIRECTORY_HANDLE_CACHE.set(cacheKey, created); + return created; +} + +function setCacheEntry(cache: Map, 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; +}): 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(); +} diff --git a/src/discord/directory-live.ts b/src/discord/directory-live.ts index 7cef2d5489f..d57d3e775a9 100644 --- a/src/discord/directory-live.ts +++ b/src/discord/directory-live.ts @@ -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", diff --git a/src/discord/mentions.test.ts b/src/discord/mentions.test.ts new file mode 100644 index 00000000000..cc457786864 --- /dev/null +++ b/src/discord/mentions.test.ts @@ -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>"); + }); +}); diff --git a/src/discord/mentions.ts b/src/discord/mentions.ts new file mode 100644 index 00000000000..28a9097ccbf --- /dev/null +++ b/src/discord/mentions.ts @@ -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; +} diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index e2b3e7371b0..4d48782047b 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -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, diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index edb2ed1f5b0..9ab05320055 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -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["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"); diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index e7415e72f71..48af44d9873 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -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)`); diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index 72ca2aea94d..2352d2f8687 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -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({ diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index 52b30c8c87c..0be421ecbbe 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -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) { diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index da4ef1ff0c4..3e261f4a278 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -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 { 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 { 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 } : {}), }, diff --git a/src/discord/send.sends-basic-channel-messages.test.ts b/src/discord/send.sends-basic-channel-messages.test.ts index bd56e7973ed..6241fce7996 100644 --- a/src/discord/send.sends-basic-channel-messages.test.ts +++ b/src/discord/send.sends-basic-channel-messages.test.ts @@ -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. diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 6f2843d2a77..3a5d71f03e4 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -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.`, }; diff --git a/src/discord/targets.ts b/src/discord/targets.ts index 9ddbae388eb..2be2b970724 100644 --- a/src/discord/targets.ts +++ b/src/discord/targets.ts @@ -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:") const userId = match.id.replace(/^user:/, ""); + rememberDiscordDirectoryUser({ + accountId: options.accountId, + userId, + handles: [trimmed, match.name, match.handle], + }); return buildMessagingTarget("user", userId, trimmed); } } catch { diff --git a/src/discord/voice/command.ts b/src/discord/voice/command.ts index 1599fec650b..835ba4d82f3 100644 --- a/src/discord/voice/command.ts +++ b/src/discord/voice/command.ts @@ -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 }); } } diff --git a/src/discord/voice/manager.ts b/src/discord/voice/manager.ts index dd1f37a8297..301e8b74c10 100644 --- a/src/discord/voice/manager.ts +++ b/src/discord/voice/manager.ts @@ -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, };