mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:40:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user