mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 02:57:27 +00:00
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
111
src/discord/directory-cache.ts
Normal file
111
src/discord/directory-cache.ts
Normal 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();
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
85
src/discord/mentions.test.ts
Normal file
85
src/discord/mentions.test.ts
Normal 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
83
src/discord/mentions.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user