fix(discord): normalize ACP thread binding targets

Normalize Discord ACP thread-binding channel targets at the REST/thread-create boundary while preserving current-conversation binding keys.\n\nThanks @Zetarcos.
This commit is contained in:
Zetarcos
2026-04-22 13:09:26 -07:00
committed by GitHub
parent 238b31a00c
commit 38001cdeaa
5 changed files with 164 additions and 4 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord: normalize prefixed channel targets only at the thread-binding API boundary, so `sessions_spawn({ runtime: "acp", thread: true })` can create child threads from Discord channels without breaking current-channel ACP bindings. (#68034) Thanks @Zetarcos.
- 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.
- Plugins/startup: pre-normalize and cache Jiti alias maps before creating plugin loaders, so module-scoped loader filenames do not reintroduce per-plugin alias-normalization startup cost. Fixes #70186.

View File

@@ -70,6 +70,35 @@ describe("resolveChannelIdForBinding", () => {
expect(restGet).not.toHaveBeenCalled();
});
it("normalizes prefixed explicit channelId without resolving route", async () => {
const resolved = await resolveChannelIdForBinding({
accountId: "default",
threadId: "thread-1",
channelId: "channel:123456789012345678",
});
expect(resolved).toBe("123456789012345678");
expect(createDiscordRestClient).not.toHaveBeenCalled();
expect(restGet).not.toHaveBeenCalled();
});
it("strips channel prefix before resolving route", async () => {
restGet.mockResolvedValueOnce({
id: "123456789012345678",
type: ChannelType.GuildText,
});
const resolved = await resolveChannelIdForBinding({
accountId: "default",
threadId: "channel:123456789012345678",
});
expect(resolved).toBe("123456789012345678");
const route = JSON.stringify(restGet.mock.calls[0]?.[0] ?? null);
expect(route).toContain("123456789012345678");
expect(route).not.toContain("channel:");
});
it("returns parent channel for thread channels", async () => {
restGet.mockResolvedValueOnce({
id: "thread-1",

View File

@@ -5,6 +5,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { createDiscordRestClient } from "../client.js";
import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
import { createThreadDiscord } from "../send.messages.js";
import { resolveDiscordChannelId } from "../target-parsing.js";
import { resolveThreadBindingPersonaFromRecord } from "./thread-bindings.persona.js";
import {
BINDINGS_BY_THREAD_ID,
@@ -50,6 +51,18 @@ function isThreadChannelType(type: unknown): boolean {
);
}
function normalizeDiscordBindingChannelId(raw?: string | null): string | null {
const trimmed = normalizeOptionalString(raw) ?? "";
if (!trimmed) {
return null;
}
try {
return resolveDiscordChannelId(trimmed);
} catch {
return null;
}
}
export function summarizeDiscordError(err: unknown): string {
if (err instanceof Error) {
return err.message;
@@ -233,10 +246,14 @@ export async function resolveChannelIdForBinding(params: {
threadId: string;
channelId?: string;
}): Promise<string | null> {
const explicit = params.channelId?.trim();
const explicit = normalizeDiscordBindingChannelId(params.channelId);
if (explicit) {
return explicit;
}
const lookupThreadId = normalizeDiscordBindingChannelId(params.threadId);
if (!lookupThreadId) {
return null;
}
try {
const rest = createDiscordRestClient(
{
@@ -245,7 +262,7 @@ export async function resolveChannelIdForBinding(params: {
},
params.cfg,
).rest;
const channel = (await rest.get(Routes.channel(params.threadId))) as {
const channel = (await rest.get(Routes.channel(lookupThreadId))) as {
id?: string;
type?: number;
parent_id?: string;
@@ -267,7 +284,7 @@ export async function resolveChannelIdForBinding(params: {
return channelId || null;
} catch (err) {
logVerbose(
`discord thread binding channel resolve failed for ${params.threadId}: ${summarizeDiscordError(err)}`,
`discord thread binding channel resolve failed for ${lookupThreadId}: ${summarizeDiscordError(err)}`,
);
return null;
}

View File

@@ -984,6 +984,105 @@ describe("thread binding lifecycle", () => {
expect(usedTokenNew).toBe(true);
});
it("normalizes prefixed parentConversationId before creating child thread bindings", async () => {
createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
hoisted.restGet.mockClear();
hoisted.createThreadDiscord.mockClear();
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-parent-normalized" });
const bound = await getSessionBindingService().bind({
targetSessionKey: "agent:codex:acp:test-parent-normalized",
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:1491611525914558668",
parentConversationId: "channel:1491611525914558667",
},
placement: "child",
metadata: {
agentId: "codex",
label: "Codex ACP bind test",
threadName: "Codex ACP bind test",
},
});
expect(bound).toMatchObject({
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-created-parent-normalized",
},
});
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
"1491611525914558667",
expect.objectContaining({ autoArchiveMinutes: 60 }),
expect.objectContaining({ accountId: "default" }),
);
expect(hoisted.restGet).not.toHaveBeenCalled();
});
it("preserves prefixed current channel conversation ids as binding keys", async () => {
createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
hoisted.restGet.mockClear();
hoisted.restPost.mockClear();
const service = getSessionBindingService();
const bound = await service.bind({
targetSessionKey: "agent:codex:acp:current-channel",
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:1491611525914558667",
},
placement: "current",
metadata: {
agentId: "codex",
},
});
expect(bound).toMatchObject({
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:1491611525914558667",
},
});
expect(
service.resolveByConversation({
channel: "discord",
accountId: "default",
conversationId: "channel:1491611525914558667",
}),
).toMatchObject({
targetSessionKey: "agent:codex:acp:current-channel",
});
expect(
service.resolveByConversation({
channel: "discord",
accountId: "default",
conversationId: "1491611525914558667",
}),
).toBeNull();
expect(hoisted.restGet).not.toHaveBeenCalled();
expect(hoisted.restPost).not.toHaveBeenCalled();
});
it("binds current Discord DMs as direct conversation bindings", async () => {
createThreadBindingManager({
accountId: "default",

View File

@@ -15,6 +15,7 @@ import {
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { createDiscordRestClient } from "../client.js";
import { resolveDiscordChannelId } from "../target-parsing.js";
import {
createThreadForBinding,
createWebhookForChannel,
@@ -67,6 +68,18 @@ function registerManager(manager: ThreadBindingManager) {
MANAGERS_BY_ACCOUNT_ID.set(manager.accountId, manager);
}
function normalizeChildBindingParentChannelId(raw?: string | null): string | undefined {
const trimmed = normalizeOptionalString(raw) ?? "";
if (!trimmed) {
return undefined;
}
try {
return resolveDiscordChannelId(trimmed);
} catch {
return undefined;
}
}
function unregisterManager(accountId: string, manager: ThreadBindingManager) {
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
if (existing === manager) {
@@ -632,11 +645,12 @@ export function createThreadBindingManager(
? normalizeOptionalString(metadata.agentId)
: undefined;
let threadId: string | undefined;
let channelId = normalizeOptionalString(input.conversation.parentConversationId);
let channelId: string | undefined;
let createThread = false;
if (placement === "child") {
createThread = true;
channelId = normalizeChildBindingParentChannelId(input.conversation.parentConversationId);
if (!channelId && conversationId) {
const cfg = resolveCurrentCfg();
channelId =