fix(discord): restore DM reactions and guild activation

This commit is contained in:
Peter Steinberger
2026-04-22 20:22:41 +01:00
parent f7a52573b0
commit 64a98dea8d
10 changed files with 413 additions and 13 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord: let `message` tool reactions resolve `user:<id>` DM targets and preserve `channels.discord.guilds.<guild>.channels.<channel>.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.

View File

@@ -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({

View File

@@ -22,6 +22,17 @@ import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-a
const providerId = "discord";
function readCurrentDiscordTarget(
toolContext: Pick<ChannelMessageActionContext, "toolContext">["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:<id>).");
}
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,
},

View File

@@ -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<string> {
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<string, unknown> {
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,
});

View File

@@ -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",

View File

@@ -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<DiscordRecipient> {
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 };
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -12,6 +12,17 @@ import { extractExplicitGroupId } from "./group-id.js";
let groupsRuntimePromise: Promise<typeof import("./groups.runtime.js")> | null = null;
type DiscordGroupConfig = {
requireMention?: boolean;
slug?: string;
channels?: Record<string, DiscordGroupConfig>;
};
type DiscordConfigWithGuilds = {
accounts?: Record<string, { guilds?: Record<string, DiscordGroupConfig> }>;
guilds?: Record<string, DiscordGroupConfig>;
};
function loadGroupsRuntime() {
groupsRuntimePromise ??= import("./groups.runtime.js");
return groupsRuntimePromise;
@@ -37,6 +48,99 @@ async function resolveRuntimeChannelId(raw?: string | null): Promise<string | nu
}
}
function normalizeDiscordSlug(value?: string | null) {
const normalized = normalizeOptionalLowercaseString(value);
if (!normalized) {
return "";
}
return normalized
.replace(/^#/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function resolveDiscordGuilds(
cfg: OpenClawConfig,
accountId?: string | null,
): Record<string, DiscordGroupConfig> | 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<string, DiscordGroupConfig> | 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<string, DiscordGroupConfig> | 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,