diff --git a/CHANGELOG.md b/CHANGELOG.md index c1332a5b3f5..0f1623cadaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord: let `message` tool reactions resolve `user:` DM targets and preserve `channels.discord.guilds..channels..requireMention: false` during reply-stage activation fallback. Fixes #70165 and #69441. - Telegram/webhooks: lower the grammY webhook callback timeout to 5s so Telegram gets an early 200 response instead of retrying long-running updates as read timeouts. (#70146) Thanks @friday-james. - Telegram/polling: rebuild the polling HTTP transport after `getUpdates` 409 conflicts, so retries use a fresh TCP connection instead of looping on a Telegram-terminated keep-alive socket. (#69873) Thanks @hclsys. - Slack/files: resolve `downloadFile` bot tokens from the runtime config when callers provide `cfg` without an explicit token or prebuilt client, preserving cfg-only file downloads outside the action runtime path. (#70160) Thanks @martingarramon. diff --git a/extensions/discord/src/actions/handle-action.test.ts b/extensions/discord/src/actions/handle-action.test.ts index 5fec28815d5..c162cf9d4cc 100644 --- a/extensions/discord/src/actions/handle-action.test.ts +++ b/extensions/discord/src/actions/handle-action.test.ts @@ -71,6 +71,55 @@ describe("handleDiscordMessageAction", () => { ); }); + it("falls back to Discord toolContext.currentChannelId for reaction targets", async () => { + await handleDiscordMessageAction({ + action: "react", + params: { + emoji: "ok", + }, + cfg: { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig, + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "user:U1", + currentMessageId: "9001", + }, + }); + + expect(handleDiscordActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "react", + channelId: "user:U1", + messageId: "9001", + emoji: "ok", + }), + expect.any(Object), + expect.any(Object), + ); + }); + + it("does not use another provider's current target for Discord reactions", async () => { + await expect( + handleDiscordMessageAction({ + action: "react", + params: { + emoji: "ok", + }, + cfg: { + channels: { discord: { token: "tok" } }, + } as OpenClawConfig, + toolContext: { + currentChannelProvider: "telegram", + currentChannelId: "user:U1", + currentMessageId: "9001", + }, + }), + ).rejects.toThrow(/channel target is required/i); + + expect(handleDiscordActionMock).not.toHaveBeenCalled(); + }); + it("rejects reactions when no message id source is available", async () => { await expect( handleDiscordMessageAction({ diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index fa79510eaa1..70614f92a29 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -22,6 +22,17 @@ import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-a const providerId = "discord"; +function readCurrentDiscordTarget( + toolContext: Pick["toolContext"], +): string | undefined { + const provider = toolContext?.currentChannelProvider?.trim().toLowerCase(); + if (provider && provider !== providerId) { + return undefined; + } + const target = toolContext?.currentChannelId?.trim(); + return target || undefined; +} + export async function handleDiscordMessageAction( ctx: Pick< ChannelMessageActionContext, @@ -44,10 +55,17 @@ export async function handleDiscordMessageAction( mediaReadFile: ctx.mediaReadFile, } as const; - const resolveChannelId = () => - resolveDiscordChannelId( - readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }), - ); + const readTarget = () => { + const target = + readStringParam(params, "channelId") ?? + readStringParam(params, "to") ?? + readCurrentDiscordTarget(ctx.toolContext); + if (!target) { + throw new Error("Discord channel target is required (use channel:)."); + } + return target; + }; + const resolveChannelId = () => resolveDiscordChannelId(readTarget()); if (action === "send") { const to = readStringParam(params, "to", { required: true }); @@ -137,7 +155,7 @@ export async function handleDiscordMessageAction( { action: "react", accountId: accountId ?? undefined, - channelId: resolveChannelId(), + channelId: readTarget(), messageId, emoji, remove, @@ -154,7 +172,7 @@ export async function handleDiscordMessageAction( { action: "reactions", accountId: accountId ?? undefined, - channelId: resolveChannelId(), + channelId: readTarget(), messageId, limit, }, diff --git a/extensions/discord/src/actions/runtime.messaging.ts b/extensions/discord/src/actions/runtime.messaging.ts index b867b4dff29..57a50a2b0ae 100644 --- a/extensions/discord/src/actions/runtime.messaging.ts +++ b/extensions/discord/src/actions/runtime.messaging.ts @@ -38,7 +38,11 @@ import { sendVoiceMessageDiscord, unpinMessageDiscord, } from "../send.js"; -import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js"; +import { + resolveDiscordTargetChannelId, + type DiscordSendComponents, + type DiscordSendEmbeds, +} from "../send.shared.js"; import { resolveDiscordChannelId } from "../targets.js"; export const discordMessagingActionRuntime = { @@ -56,6 +60,7 @@ export const discordMessagingActionRuntime = { readMessagesDiscord, removeOwnReactionsDiscord, removeReactionDiscord, + resolveDiscordReactionTargetChannelId, resolveDiscordChannelId, searchMessagesDiscord, sendDiscordComponentMessage, @@ -66,6 +71,23 @@ export const discordMessagingActionRuntime = { unpinMessageDiscord, }; +export async function resolveDiscordReactionTargetChannelId(params: { + target: string; + cfg: OpenClawConfig; + accountId?: string; +}): Promise { + try { + return resolveDiscordChannelId(params.target); + } catch { + return ( + await resolveDiscordTargetChannelId(params.target, { + cfg: params.cfg, + accountId: params.accountId, + }) + ).channelId; + } +} + function hasDiscordComponentObjectKeys(value: unknown): value is Record { return Boolean( value && @@ -114,6 +136,15 @@ export async function handleDiscordMessagingAction( } const cfgOptions = { cfg }; const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(cfg); + const resolveReactionChannelId = async () => { + const target = + readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }); + return await discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId({ + target, + cfg, + accountId: resolvedReactionAccountId, + }); + }; const reactionRuntimeOptions = resolvedReactionAccountId ? createDiscordRuntimeAccountContext({ cfg, @@ -138,7 +169,7 @@ export async function handleDiscordMessagingAction( if (!isActionEnabled("reactions")) { throw new Error("Discord reactions are disabled."); } - const channelId = resolveChannelId(); + const channelId = await resolveReactionChannelId(); const messageId = readStringParam(params, "messageId", { required: true, }); @@ -174,7 +205,7 @@ export async function handleDiscordMessagingAction( if (!isActionEnabled("reactions")) { throw new Error("Discord reactions are disabled."); } - const channelId = resolveChannelId(); + const channelId = await resolveReactionChannelId(); const messageId = readStringParam(params, "messageId", { required: true, }); diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index f050ca3ca17..4d11d96e551 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -62,6 +62,7 @@ const { createThreadDiscord, deleteChannelDiscord, editChannelDiscord, + fetchReactionsDiscord, fetchMessageDiscord, kickMemberDiscord, listGuildChannelsDiscord, @@ -198,6 +199,56 @@ describe("handleDiscordMessagingAction", () => { ); }); + it("resolves Discord DM targets for reaction adds", async () => { + const resolveReactionTarget = vi.fn(async () => "DM1"); + discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget; + + await handleMessagingAction( + "react", + { + to: "user:U1", + messageId: "M1", + emoji: "✅", + }, + enableAllActions, + ); + + expect(resolveReactionTarget).toHaveBeenCalledWith({ + target: "user:U1", + cfg: DISCORD_TEST_CFG, + accountId: "default", + }); + expect(reactMessageDiscord).toHaveBeenCalledWith("DM1", "M1", "✅", { + cfg: DISCORD_TEST_CFG, + accountId: "default", + }); + }); + + it("resolves Discord DM targets for reaction listing", async () => { + const resolveReactionTarget = vi.fn(async () => "DM1"); + discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget; + + await handleMessagingAction( + "reactions", + { + to: "user:U1", + messageId: "M1", + }, + enableAllActions, + ); + + expect(resolveReactionTarget).toHaveBeenCalledWith({ + target: "user:U1", + cfg: DISCORD_TEST_CFG, + accountId: "default", + }); + expect(fetchReactionsDiscord).toHaveBeenCalledWith("DM1", "M1", { + cfg: DISCORD_TEST_CFG, + accountId: "default", + limit: undefined, + }); + }); + it("removes reactions on empty emoji", async () => { await handleMessagingAction( "react", diff --git a/extensions/discord/src/recipient-resolution.ts b/extensions/discord/src/recipient-resolution.ts index 2b325828eca..c61f74e63ee 100644 --- a/extensions/discord/src/recipient-resolution.ts +++ b/extensions/discord/src/recipient-resolution.ts @@ -1,6 +1,7 @@ import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveDiscordAccount } from "./accounts.js"; import { parseAndResolveDiscordTarget } from "./target-resolver.js"; +import type { DiscordTargetParseOptions } from "./targets.js"; type DiscordRecipient = | { @@ -16,6 +17,7 @@ export async function parseAndResolveRecipient( raw: string, accountId?: string, cfg?: OpenClawConfig, + parseOptions: DiscordTargetParseOptions = {}, ): Promise { if (!cfg) { throw new Error( @@ -25,7 +27,8 @@ export async function parseAndResolveRecipient( const resolvedCfg = requireRuntimeConfig(cfg, "Discord recipient resolution"); const accountInfo = resolveDiscordAccount({ cfg: resolvedCfg, accountId }); const trimmed = raw.trim(); - const parseOptions = { + const resolvedParseOptions = { + ...parseOptions, ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`, }; const resolved = await parseAndResolveDiscordTarget( @@ -34,7 +37,7 @@ export async function parseAndResolveRecipient( cfg: resolvedCfg, accountId: accountInfo.accountId, }, - parseOptions, + resolvedParseOptions, ); return { kind: resolved.kind, id: resolved.id }; } diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index 7c82e8fae8b..5f29034cf19 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -16,6 +16,7 @@ let removeReactionDiscord: typeof import("./send.js").removeReactionDiscord; let searchMessagesDiscord: typeof import("./send.js").searchMessagesDiscord; let sendMessageDiscord: typeof import("./send.js").sendMessageDiscord; let unpinMessageDiscord: typeof import("./send.js").unpinMessageDiscord; +let resolveDiscordTargetChannelId: typeof import("./send.shared.js").resolveDiscordTargetChannelId; let loadWebMedia: typeof import("openclaw/plugin-sdk/web-media").loadWebMedia; let __resetDiscordDirectoryCacheForTest: typeof import("./directory-cache.js").__resetDiscordDirectoryCacheForTest; let rememberDiscordDirectoryUser: typeof import("./directory-cache.js").rememberDiscordDirectoryUser; @@ -39,6 +40,7 @@ beforeAll(async () => { sendMessageDiscord, unpinMessageDiscord, } = await import("./send.js")); + ({ resolveDiscordTargetChannelId } = await import("./send.shared.js")); ({ loadWebMedia } = await import("openclaw/plugin-sdk/web-media")); ({ __resetDiscordDirectoryCacheForTest, rememberDiscordDirectoryUser } = await import("./directory-cache.js")); @@ -49,6 +51,39 @@ beforeEach(() => { __resetDiscordDirectoryCacheForTest(); }); +describe("resolveDiscordTargetChannelId", () => { + it("creates a DM channel for user targets", async () => { + const { rest, postMock } = makeDiscordRest(); + postMock.mockResolvedValueOnce({ id: "dm-1" }); + + await expect( + resolveDiscordTargetChannelId("user:U1", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }), + ).resolves.toEqual({ channelId: "dm-1", dm: true }); + + expect(postMock).toHaveBeenCalledWith(Routes.userChannels(), { + body: { recipient_id: "U1" }, + }); + }); + + it("keeps channel targets on the channel path", async () => { + const { rest, postMock } = makeDiscordRest(); + + await expect( + resolveDiscordTargetChannelId("channel:C1", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + }), + ).resolves.toEqual({ channelId: "C1" }); + + expect(postMock).not.toHaveBeenCalled(); + }); +}); + describe("sendMessageDiscord", () => { function expectReplyReference( body: { message_reference?: unknown } | undefined, diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index baa35a4b60e..cba692f30e8 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -9,7 +9,7 @@ import { import { PollLayoutType } from "discord-api-types/payloads/v10"; import type { RESTAPIPoll } from "discord-api-types/rest/v10"; import { Routes, type APIChannel, type APIEmbed } from "discord-api-types/v10"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { requireRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime"; import { extensionForMime } from "openclaw/plugin-sdk/media-runtime"; import { @@ -22,7 +22,8 @@ import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload import type { RetryRunner } from "openclaw/plugin-sdk/retry-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { chunkDiscordTextWithMode } from "./chunk.js"; -import { createDiscordClient, resolveDiscordRest } from "./client.js"; +import { createDiscordClient, resolveDiscordRest, type DiscordClientOpts } from "./client.js"; +import { parseAndResolveRecipient } from "./recipient-resolution.js"; import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js"; import { DiscordSendError } from "./send.types.js"; @@ -193,6 +194,18 @@ async function resolveChannelId( return { channelId: dmChannel.id, dm: true }; } +async function resolveDiscordTargetChannelId( + raw: string, + opts: DiscordClientOpts & { cfg: OpenClawConfig }, +): Promise<{ channelId: string; dm?: boolean }> { + const cfg = requireRuntimeConfig(opts.cfg, "Discord target channel resolution"); + const recipient = await parseAndResolveRecipient(raw, opts.accountId, cfg, { + defaultKind: "channel", + }); + const { rest, request } = createDiscordClient(opts, cfg); + return await resolveChannelId(rest, recipient, request); +} + export async function resolveDiscordChannelType( rest: RequestClient, channelId: string, @@ -455,6 +468,7 @@ export { normalizeReactionEmoji, normalizeStickerIds, resolveChannelId, + resolveDiscordTargetChannelId, resolveDiscordRest, sendDiscordMedia, sendDiscordText, diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts index 79bedf2cb28..6cb2fc9db4d 100644 --- a/src/auto-reply/reply/groups.test.ts +++ b/src/auto-reply/reply/groups.test.ts @@ -84,4 +84,87 @@ describe("group runtime loading", () => { expect(groupsRuntimeLoads).toHaveBeenCalled(); vi.doUnmock("./groups.runtime.js"); }); + + it("honors Discord guild channel requireMention fallback when runtime plugin is unavailable", async () => { + vi.doMock("./groups.runtime.js", () => ({ + getChannelPlugin: () => undefined, + normalizeChannelId: (channelId?: string) => channelId?.trim().toLowerCase(), + })); + const groups = await import("./groups.js"); + + await expect( + groups.resolveGroupRequireMention({ + cfg: { + channels: { + discord: { + guilds: { + G1: { + requireMention: true, + channels: { + C1: { requireMention: false }, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig, + ctx: { + Provider: "discord", + From: "discord:channel:C1", + GroupSpace: "G1", + GroupChannel: "general", + }, + groupResolution: { + key: "discord:channel:C1", + channel: "discord", + id: "C1", + chatType: "group", + }, + }), + ).resolves.toBe(false); + vi.doUnmock("./groups.runtime.js"); + }); + + it("honors account-scoped Discord guild requireMention fallback", async () => { + vi.doMock("./groups.runtime.js", () => ({ + getChannelPlugin: () => undefined, + normalizeChannelId: (channelId?: string) => channelId?.trim().toLowerCase(), + })); + const groups = await import("./groups.js"); + + await expect( + groups.resolveGroupRequireMention({ + cfg: { + channels: { + discord: { + guilds: { + G1: { requireMention: true }, + }, + accounts: { + work: { + guilds: { + G1: { requireMention: false }, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig, + ctx: { + Provider: "discord", + From: "discord:channel:C1", + GroupSpace: "G1", + GroupChannel: "general", + AccountId: "work", + }, + groupResolution: { + key: "discord:channel:C1", + channel: "discord", + id: "C1", + chatType: "group", + }, + }), + ).resolves.toBe(false); + vi.doUnmock("./groups.runtime.js"); + }); }); diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index e340e41e738..df75313118a 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -12,6 +12,17 @@ import { extractExplicitGroupId } from "./group-id.js"; let groupsRuntimePromise: Promise | null = null; +type DiscordGroupConfig = { + requireMention?: boolean; + slug?: string; + channels?: Record; +}; + +type DiscordConfigWithGuilds = { + accounts?: Record }>; + guilds?: Record; +}; + function loadGroupsRuntime() { groupsRuntimePromise ??= import("./groups.runtime.js"); return groupsRuntimePromise; @@ -37,6 +48,99 @@ async function resolveRuntimeChannelId(raw?: string | null): Promise | undefined { + const discord = cfg.channels?.discord as DiscordConfigWithGuilds | undefined; + if (!discord) { + return undefined; + } + const normalizedAccountId = normalizeOptionalString(accountId); + const accountGuilds = normalizedAccountId + ? discord.accounts?.[normalizedAccountId]?.guilds + : undefined; + return accountGuilds ?? discord.guilds; +} + +function resolveDiscordGuildEntry( + guilds: Record | undefined, + groupSpace?: string | null, +): DiscordGroupConfig | undefined { + if (!guilds || Object.keys(guilds).length === 0) { + return undefined; + } + const space = normalizeOptionalString(groupSpace) ?? ""; + if (space && guilds[space]) { + return guilds[space]; + } + const slug = normalizeDiscordSlug(space); + if (slug && guilds[slug]) { + return guilds[slug]; + } + if (slug) { + const match = Object.values(guilds).find((entry) => normalizeDiscordSlug(entry?.slug) === slug); + if (match) { + return match; + } + } + return guilds["*"]; +} + +function resolveDiscordChannelEntry( + channels: Record | undefined, + params: { groupId?: string | null; groupChannel?: string | null }, +): DiscordGroupConfig | undefined { + if (!channels || Object.keys(channels).length === 0) { + return undefined; + } + const groupId = normalizeOptionalString(params.groupId); + const groupChannel = normalizeOptionalString(params.groupChannel); + const channelSlug = normalizeDiscordSlug(groupChannel); + return ( + (groupId ? channels[groupId] : undefined) ?? + (channelSlug ? (channels[channelSlug] ?? channels[`#${channelSlug}`]) : undefined) ?? + (groupChannel ? channels[groupChannel] : undefined) ?? + channels["*"] + ); +} + +function resolveDiscordRequireMentionFallback(params: { + cfg: OpenClawConfig; + channel: string; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + accountId?: string | null; +}): boolean | undefined { + if (params.channel !== "discord") { + return undefined; + } + const guildEntry = resolveDiscordGuildEntry( + resolveDiscordGuilds(params.cfg, params.accountId), + params.groupSpace, + ); + const channelEntry = resolveDiscordChannelEntry(guildEntry?.channels, params); + if (typeof channelEntry?.requireMention === "boolean") { + return channelEntry.requireMention; + } + if (typeof guildEntry?.requireMention === "boolean") { + return guildEntry.requireMention; + } + return undefined; +} + export async function resolveGroupRequireMention(params: { cfg: OpenClawConfig; ctx: TemplateContext; @@ -70,6 +174,17 @@ export async function resolveGroupRequireMention(params: { if (typeof requireMention === "boolean") { return requireMention; } + const discordRequireMention = resolveDiscordRequireMentionFallback({ + cfg, + channel, + groupId, + groupChannel, + groupSpace, + accountId: ctx.AccountId, + }); + if (typeof discordRequireMention === "boolean") { + return discordRequireMention; + } return resolveChannelGroupRequireMention({ cfg, channel,