fix(discord): harden partial thread channels

This commit is contained in:
Peter Steinberger
2026-04-22 20:41:14 +01:00
parent e71da6705b
commit f97c6f8a04
8 changed files with 171 additions and 13 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when `name`, `parentId`, `parent`, or `ownerId` requires fetched raw data.
- 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.

View File

@@ -53,8 +53,12 @@ export function resolveDiscordChannelParentIdSafe(channel: unknown): string | un
return resolveDiscordChannelStringPropertySafe(channel, "parentId");
}
export function resolveDiscordChannelParentSafe(channel: unknown): unknown {
return readDiscordChannelPropertySafe(channel, "parent");
}
export function resolveDiscordChannelInfoSafe(channel: unknown): DiscordChannelInfoSafe {
const parent = readDiscordChannelPropertySafe(channel, "parent");
const parent = resolveDiscordChannelParentSafe(channel);
return {
name: resolveDiscordChannelNameSafe(channel),
topic: resolveDiscordChannelTopicSafe(channel),

View File

@@ -1,5 +1,6 @@
import { Message } from "@buape/carbon";
import { describe, expect, it } from "vitest";
import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js";
import {
buildDiscordInboundJob,
materializeDiscordInboundJob,
@@ -90,6 +91,33 @@ describe("buildDiscordInboundJob", () => {
expect(() => JSON.stringify(job.payload)).not.toThrow();
});
it("normalizes partial thread channels without reading throwing getters", async () => {
const threadChannel = createPartialDiscordChannelWithThrowingGetters(
{
id: "thread-1",
name: "codex",
parentId: "forum-1",
parent: { id: "forum-1", name: "Forum" },
ownerId: "user-1",
},
["name", "parentId", "parent", "ownerId"],
);
const ctx = await createBaseDiscordMessageContext({
threadChannel,
});
const job = buildDiscordInboundJob(ctx);
expect(job.payload.threadChannel).toEqual({
id: "thread-1",
name: undefined,
parentId: undefined,
parent: undefined,
ownerId: undefined,
});
expect(() => JSON.stringify(job.payload)).not.toThrow();
});
it("re-materializes the process context with an overridden abort signal", async () => {
const ctx = await createBaseDiscordMessageContext();
const job = buildDiscordInboundJob(ctx, { replayKeys: ["default:ch-1:m-1"] });

View File

@@ -1,4 +1,9 @@
import { resolveDiscordChannelNameSafe } from "./channel-access.js";
import {
resolveDiscordChannelIdSafe,
resolveDiscordChannelInfoSafe,
resolveDiscordChannelNameSafe,
resolveDiscordChannelParentSafe,
} from "./channel-access.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js";
type DiscordInboundJobRuntimeField =
@@ -102,16 +107,18 @@ function normalizeDiscordThreadChannel(
if (!threadChannel) {
return null;
}
const channelInfo = resolveDiscordChannelInfoSafe(threadChannel);
const parent = resolveDiscordChannelParentSafe(threadChannel);
return {
id: threadChannel.id,
name: threadChannel.name,
parentId: threadChannel.parentId,
parent: threadChannel.parent
name: channelInfo.name,
parentId: channelInfo.parentId,
parent: parent
? {
id: threadChannel.parent.id,
name: resolveDiscordChannelNameSafe(threadChannel.parent),
id: resolveDiscordChannelIdSafe(parent),
name: resolveDiscordChannelNameSafe(parent),
}
: undefined,
ownerId: threadChannel.ownerId,
ownerId: channelInfo.ownerId,
};
}

View File

@@ -1,5 +1,6 @@
import { ChannelType } from "@buape/carbon";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js";
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn());
@@ -757,6 +758,67 @@ describe("preflightDiscordMessage", () => {
expect(result?.shouldRequireMention).toBe(false);
});
it("handles partial thread channel owner getters during mention preflight", async () => {
const threadId = "thread-partial-owner";
const parentId = "parent-partial-owner";
const message = createDiscordMessage({
id: "m-thread-partial-owner",
channelId: threadId,
content: "thread hello",
author: {
id: "user-1",
bot: false,
username: "Peter",
},
});
Object.defineProperty(message, "channel", {
value: createPartialDiscordChannelWithThrowingGetters(
{
id: threadId,
isThread: () => true,
ownerId: "owner-1",
parentId,
parent: { id: parentId, name: "general" },
},
["ownerId", "parentId", "parent"],
),
configurable: true,
enumerable: true,
});
const result = await preflightDiscordMessage({
...createPreflightArgs({
cfg: DEFAULT_PREFLIGHT_CFG,
discordConfig: {} as DiscordConfig,
data: createGuildEvent({
channelId: threadId,
guildId: "guild-1",
author: message.author,
message,
includeGuildObject: false,
}),
client: createThreadClient({
threadId,
parentId,
}),
}),
guildEntries: {
"guild-1": {
channels: {
[parentId]: {
enabled: true,
requireMention: false,
},
},
},
},
});
expect(result).not.toBeNull();
expect(result?.threadParentId).toBe(parentId);
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";

View File

@@ -32,7 +32,7 @@ import {
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
} from "./allow-list.js";
import { resolveDiscordChannelNameSafe } from "./channel-access.js";
import { resolveDiscordChannelInfoSafe, resolveDiscordChannelNameSafe } from "./channel-access.js";
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
import {
@@ -849,7 +849,9 @@ export async function preflightDiscordMessage(
} satisfies HistoryEntry)
: undefined;
const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
const threadOwnerId = threadChannel
? (resolveDiscordChannelInfoSafe(threadChannel).ownerId ?? channelInfo?.ownerId)
: undefined;
const shouldRequireMentionByConfig = resolveDiscordShouldRequireMention({
isGuildMessage,
isThread: Boolean(threadChannel),

View File

@@ -1,5 +1,6 @@
import { ChannelType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPartialDiscordChannelWithThrowingGetters } from "../test-support/partial-channel.js";
import { __resetDiscordChannelInfoCacheForTest } from "./message-utils.js";
import { resolveDiscordThreadParentInfo } from "./threading.js";
@@ -50,6 +51,50 @@ describe("resolveDiscordThreadParentInfo", () => {
});
});
it("falls back to fetched thread parentId when partial channel getters throw", async () => {
const fetchChannel = vi.fn(async (channelId: string) => {
if (channelId === "thread-1") {
return {
id: "thread-1",
type: ChannelType.PublicThread,
name: "thread-name",
parentId: "parent-1",
};
}
if (channelId === "parent-1") {
return {
id: "parent-1",
type: ChannelType.GuildText,
name: "parent-name",
};
}
return null;
});
const client = { fetchChannel } as unknown as import("@buape/carbon").Client;
const threadChannel = createPartialDiscordChannelWithThrowingGetters(
{
id: "thread-1",
parent: { id: "stale-parent", name: "stale-parent-name" },
},
["parentId", "parent"],
);
const result = await resolveDiscordThreadParentInfo({
client,
threadChannel,
channelInfo: null,
});
expect(fetchChannel).toHaveBeenCalledWith("thread-1");
expect(fetchChannel).toHaveBeenCalledWith("parent-1");
expect(result).toEqual({
id: "parent-1",
name: "parent-name",
type: ChannelType.GuildText,
});
});
it("does not fetch thread info when parentId is already present", async () => {
const fetchChannel = vi.fn(async (channelId: string) => {
if (channelId === "parent-1") {

View File

@@ -14,7 +14,12 @@ import {
truncateUtf16Safe,
} from "openclaw/plugin-sdk/text-runtime";
import type { DiscordChannelConfigResolved } from "./allow-list.js";
import { resolveDiscordChannelNameSafe } from "./channel-access.js";
import {
resolveDiscordChannelIdSafe,
resolveDiscordChannelNameSafe,
resolveDiscordChannelParentIdSafe,
resolveDiscordChannelParentSafe,
} from "./channel-access.js";
import {
resolveDiscordChannelInfo,
resolveDiscordEmbedText,
@@ -199,8 +204,12 @@ export async function resolveDiscordThreadParentInfo(params: {
channelInfo: import("./message-utils.js").DiscordChannelInfo | null;
}): Promise<DiscordThreadParentInfo> {
const { threadChannel, channelInfo, client } = params;
const parent = resolveDiscordChannelParentSafe(threadChannel);
let parentId =
threadChannel.parentId ?? threadChannel.parent?.id ?? channelInfo?.parentId ?? undefined;
resolveDiscordChannelParentIdSafe(threadChannel) ??
resolveDiscordChannelIdSafe(parent) ??
channelInfo?.parentId ??
undefined;
if (!parentId && threadChannel.id) {
const threadInfo = await resolveDiscordChannelInfo(client, threadChannel.id);
parentId = threadInfo?.parentId ?? undefined;
@@ -208,7 +217,7 @@ export async function resolveDiscordThreadParentInfo(params: {
if (!parentId) {
return {};
}
let parentName = resolveDiscordChannelNameSafe(threadChannel.parent);
let parentName = resolveDiscordChannelNameSafe(parent);
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
parentName = parentName ?? parentInfo?.name;
const parentType = parentInfo?.type;