Skip to content

Commit 63621ee

Browse files
SYU8384Hex
andauthored
fix(discord): route thread bindings to plugin owners
Route Discord thread follow-up messages to plugin-owned bindings by the raw thread id while retaining parent channel fallback matching. This fixes `/codex bind` follow-ups in Discord threads being claimed by the parent OpenClaw route instead of the bound Codex session. Verification: - `node scripts/run-vitest.mjs extensions/discord/src/channel.conversation.test.ts src/hooks/message-hook-mappers.test.ts extensions/discord/src/monitor/message-handler.process.test.ts -t "prefers bound session keys|passes Discord thread parent only|routes Discord thread plugin-owned bindings|passes thread parent ids|thread binding"` - `node scripts/run-vitest.mjs src/auto-reply/reply/dispatch-from-config.test.ts -t "routes Discord thread plugin-owned bindings by raw thread id"` - `pnpm build` - `pnpm lint --threads=8` - `CI=true FORCE_COLOR=0 pnpm lint --threads=8` - `.agents/skills/autoreview/scripts/autoreview --mode local` - GitHub: Real behavior proof, check-test-types, check-dependencies, check-prod-types, auto-reply dispatch shard, hooks shard, and extension package boundary passed on head 1e896d9. Known unrelated CI noise at merge: broad opengrep/test/lint CI failures are outside the touched Discord/session-binding surface and contradicted by focused local proof where applicable. Co-authored-by: Hex <[email protected]>
1 parent fbb776d commit 63621ee

11 files changed

Lines changed: 336 additions & 16 deletions
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
matchDiscordAcpConversation,
4+
resolveDiscordInboundConversation,
5+
} from "./channel.conversation.js";
6+
7+
describe("Discord conversation identity", () => {
8+
it("uses raw thread ids with parent channel ids for inbound thread conversations", () => {
9+
expect(
10+
resolveDiscordInboundConversation({
11+
from: "discord:user:570610468352294922",
12+
to: "channel:1510164477642014740",
13+
conversationId: "channel:1510164477642014740",
14+
threadId: "1510164477642014740",
15+
threadParentId: "1510164477642014999",
16+
isGroup: true,
17+
}),
18+
).toEqual({
19+
conversationId: "1510164477642014740",
20+
parentConversationId: "channel:1510164477642014999",
21+
});
22+
});
23+
24+
it("falls back to the current target when inbound thread parent ids are unavailable", () => {
25+
expect(
26+
resolveDiscordInboundConversation({
27+
from: "discord:user:570610468352294922",
28+
to: "channel:1510164477642014740",
29+
conversationId: "channel:1510164477642014740",
30+
threadId: "1510164477642014740",
31+
isGroup: true,
32+
}),
33+
).toEqual({
34+
conversationId: "1510164477642014740",
35+
parentConversationId: "channel:1510164477642014740",
36+
});
37+
});
38+
39+
it("keeps top-level channel conversations prefixed", () => {
40+
expect(
41+
resolveDiscordInboundConversation({
42+
from: "discord:user:570610468352294922",
43+
to: "channel:1510164477642014740",
44+
conversationId: "channel:1510164477642014740",
45+
isGroup: true,
46+
}),
47+
).toEqual({ conversationId: "channel:1510164477642014740" });
48+
});
49+
50+
it("matches configured parent channel bindings for inbound thread conversations", () => {
51+
const resolved = resolveDiscordInboundConversation({
52+
from: "discord:user:570610468352294922",
53+
to: "channel:1510164477642014740",
54+
conversationId: "channel:1510164477642014740",
55+
threadId: "1510164477642014740",
56+
threadParentId: "1510164477642014999",
57+
isGroup: true,
58+
});
59+
60+
expect(
61+
resolved &&
62+
matchDiscordAcpConversation({
63+
bindingConversationId: "channel:1510164477642014999",
64+
conversationId: resolved.conversationId,
65+
parentConversationId: resolved.parentConversationId,
66+
}),
67+
).toEqual({
68+
conversationId: "channel:1510164477642014999",
69+
matchPriority: 1,
70+
});
71+
});
72+
73+
it("prefers exact thread bindings over parent channel fallback", () => {
74+
expect(
75+
matchDiscordAcpConversation({
76+
bindingConversationId: "1510164477642014740",
77+
conversationId: "1510164477642014740",
78+
parentConversationId: "channel:1510164477642014999",
79+
}),
80+
).toEqual({
81+
conversationId: "1510164477642014740",
82+
matchPriority: 2,
83+
});
84+
});
85+
});

extensions/discord/src/channel.conversation.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
normalizeLowercaseStringOrEmpty,
3-
normalizeOptionalString,
43
normalizeOptionalStringifiedId,
54
} from "openclaw/plugin-sdk/string-coerce-runtime";
65
import { resolveDiscordCurrentConversationIdentity } from "./conversation-identity.js";
@@ -96,7 +95,7 @@ function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefin
9695
}
9796

9897
export function resolveDiscordCommandConversation(params: {
99-
threadId?: string;
98+
threadId?: string | number;
10099
threadParentId?: string;
101100
parentSessionKey?: string;
102101
from?: string;
@@ -105,18 +104,9 @@ export function resolveDiscordCommandConversation(params: {
105104
commandTo?: string;
106105
fallbackTo?: string;
107106
}) {
108-
const targets = [params.originatingTo, params.commandTo, params.fallbackTo];
109-
if (params.threadId) {
110-
const parentConversationId =
111-
normalizeDiscordMessagingTarget(normalizeOptionalString(params.threadParentId) ?? "") ||
112-
parseDiscordParentChannelFromSessionKey(params.parentSessionKey) ||
113-
resolveDiscordConversationIdFromTargets(targets);
114-
return {
115-
conversationId: params.threadId,
116-
...(parentConversationId && parentConversationId !== params.threadId
117-
? { parentConversationId }
118-
: {}),
119-
};
107+
const threadConversation = resolveDiscordThreadConversationRef(params);
108+
if (threadConversation) {
109+
return threadConversation;
120110
}
121111
const conversationId = resolveDiscordCurrentConversationIdentity({
122112
from: params.from,
@@ -128,12 +118,53 @@ export function resolveDiscordCommandConversation(params: {
128118
return conversationId ? { conversationId } : null;
129119
}
130120

121+
export function resolveDiscordThreadConversationRef(params: {
122+
threadId?: string | number | null;
123+
threadParentId?: string | number | null;
124+
parentSessionKey?: string | null;
125+
originatingTo?: string;
126+
to?: string;
127+
commandTo?: string;
128+
fallbackTo?: string;
129+
conversationId?: string;
130+
}) {
131+
const threadId = normalizeOptionalStringifiedId(params.threadId);
132+
if (!threadId) {
133+
return null;
134+
}
135+
const targets = [
136+
params.originatingTo ?? params.to,
137+
params.commandTo,
138+
params.fallbackTo ?? params.conversationId,
139+
];
140+
const parentConversationId =
141+
normalizeDiscordMessagingTarget(normalizeOptionalStringifiedId(params.threadParentId) ?? "") ||
142+
parseDiscordParentChannelFromSessionKey(params.parentSessionKey) ||
143+
resolveDiscordConversationIdFromTargets(targets);
144+
145+
return {
146+
conversationId: threadId,
147+
...(parentConversationId && parentConversationId !== threadId ? { parentConversationId } : {}),
148+
};
149+
}
150+
131151
export function resolveDiscordInboundConversation(params: {
132152
from?: string;
133153
to?: string;
134154
conversationId?: string;
155+
threadId?: string | number;
156+
threadParentId?: string | number;
135157
isGroup: boolean;
136158
}) {
159+
const threadConversation = resolveDiscordThreadConversationRef({
160+
to: params.to,
161+
conversationId: params.conversationId,
162+
threadId: params.threadId,
163+
threadParentId: params.threadParentId,
164+
});
165+
if (threadConversation) {
166+
return threadConversation;
167+
}
137168
const conversationId = resolveDiscordCurrentConversationIdentity({
138169
from: params.from,
139170
chatType: params.isGroup ? "group" : "direct",

extensions/discord/src/channel.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,22 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
314314
messaging: {
315315
targetPrefixes: ["discord"],
316316
normalizeTarget: normalizeDiscordMessagingTarget,
317-
resolveInboundConversation: ({ from, to, conversationId, isGroup }) =>
318-
resolveDiscordInboundConversation({ from, to, conversationId, isGroup }),
317+
resolveInboundConversation: ({
318+
from,
319+
to,
320+
conversationId,
321+
threadId,
322+
threadParentId,
323+
isGroup,
324+
}) =>
325+
resolveDiscordInboundConversation({
326+
from,
327+
to,
328+
conversationId,
329+
threadId,
330+
threadParentId,
331+
isGroup,
332+
}),
319333
normalizeExplicitSessionKey: ({ sessionKey, ctx }) =>
320334
normalizeExplicitDiscordSessionKey(sessionKey, ctx),
321335
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),

extensions/discord/src/monitor/message-handler.context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ export async function buildDiscordMessageProcessContext(params: {
340340
id: messageChannelId,
341341
label: fromLabel,
342342
spaceId: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
343+
parentId: threadChannel ? threadParentId : undefined,
343344
threadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined,
344345
},
345346
route: {

extensions/discord/src/monitor/message-handler.process.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,6 +1863,7 @@ describe("processDiscordMessage session routing", () => {
18631863
expectRecordFields(requireRecord(getLastDispatchCtx(), "dispatch context"), {
18641864
SessionKey: "agent:main:discord:channel:thread-1",
18651865
MessageThreadId: "thread-1",
1866+
ThreadParentId: "parent-1",
18661867
ModelParentSessionKey: "agent:main:discord:channel:parent-1",
18671868
});
18681869
expect(getLastDispatchCtx()?.ParentSessionKey).toBeUndefined();

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
22
import { clearAgentHarnesses, registerAgentHarness } from "../../agents/harness/registry.js";
3+
import type { ChannelMessagingAdapter } from "../../channels/plugins/types.core.js";
34
import type { OpenClawConfig } from "../../config/config.js";
45
import {
56
clearApprovalNativeRouteStateForTest,
@@ -38,6 +39,9 @@ import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
3839
import { buildTestCtx } from "./test-ctx.js";
3940

4041
type AbortResult = { handled: boolean; aborted: boolean; stoppedSubagents?: number };
42+
type ResolveInboundConversationParams = Parameters<
43+
NonNullable<ChannelMessagingAdapter["resolveInboundConversation"]>
44+
>[0];
4145

4246
const mocks = vi.hoisted(() => ({
4347
routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })),
@@ -5210,6 +5214,126 @@ describe("dispatchReplyFromConfig", () => {
52105214
expect(replyResolver).not.toHaveBeenCalled();
52115215
});
52125216

5217+
it("routes Discord thread plugin-owned bindings by raw thread id", async () => {
5218+
setNoAbort();
5219+
setActivePluginRegistry(
5220+
createTestRegistry([
5221+
{
5222+
pluginId: "discord",
5223+
source: "test",
5224+
plugin: {
5225+
...createChannelTestPluginBase({ id: "discord" }),
5226+
messaging: {
5227+
resolveInboundConversation: ({
5228+
to,
5229+
conversationId,
5230+
threadId,
5231+
threadParentId,
5232+
}: ResolveInboundConversationParams) =>
5233+
threadId
5234+
? {
5235+
conversationId: String(threadId),
5236+
...(threadParentId
5237+
? { parentConversationId: `channel:${threadParentId}` }
5238+
: {}),
5239+
}
5240+
: { conversationId: to ?? conversationId },
5241+
},
5242+
},
5243+
},
5244+
]),
5245+
);
5246+
hookMocks.runner.hasHooks.mockImplementation(
5247+
((hookName?: string) =>
5248+
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
5249+
);
5250+
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
5251+
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
5252+
status: "handled",
5253+
result: { handled: true },
5254+
});
5255+
sessionBindingMocks.resolveByConversation.mockImplementation(
5256+
(ref: {
5257+
channel: string;
5258+
accountId: string;
5259+
conversationId: string;
5260+
parentConversationId?: string;
5261+
}) =>
5262+
ref.channel === "discord" &&
5263+
ref.accountId === "default" &&
5264+
ref.conversationId === "1510164477642014740"
5265+
? ({
5266+
bindingId: "binding-discord-thread",
5267+
targetSessionKey: "plugin-binding:codex:thread",
5268+
targetKind: "session",
5269+
conversation: {
5270+
channel: "discord",
5271+
accountId: "default",
5272+
conversationId: "1510164477642014740",
5273+
parentConversationId: "channel:1510164477642014999",
5274+
},
5275+
status: "active",
5276+
boundAt: 1710000000000,
5277+
metadata: {
5278+
pluginBindingOwner: "plugin",
5279+
pluginId: "openclaw-codex-app-server",
5280+
pluginRoot: "/plugins/openclaw-codex-app-server",
5281+
},
5282+
} satisfies SessionBindingRecord)
5283+
: null,
5284+
);
5285+
const dispatcher = createDispatcher();
5286+
const ctx = buildTestCtx({
5287+
Provider: "discord",
5288+
Surface: "discord",
5289+
OriginatingChannel: "discord",
5290+
OriginatingTo: "channel:1510164477642014740",
5291+
To: "channel:1510164477642014740",
5292+
AccountId: "default",
5293+
SenderId: "user-9",
5294+
CommandAuthorized: true,
5295+
WasMentioned: false,
5296+
RawBody: "continue",
5297+
Body: "continue",
5298+
MessageSid: "msg-claim-discord-thread",
5299+
MessageThreadId: "1510164477642014740",
5300+
ThreadParentId: "1510164477642014999",
5301+
SessionKey: "agent:main:discord:channel:1510164477642014740",
5302+
});
5303+
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
5304+
5305+
const result = await dispatchReplyFromConfig({
5306+
ctx,
5307+
cfg: emptyConfig,
5308+
dispatcher,
5309+
replyResolver,
5310+
});
5311+
5312+
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
5313+
expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith(
5314+
expect.objectContaining({
5315+
channel: "discord",
5316+
accountId: "default",
5317+
conversationId: "1510164477642014740",
5318+
parentConversationId: "channel:1510164477642014999",
5319+
}),
5320+
);
5321+
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
5322+
"openclaw-codex-app-server",
5323+
expect.objectContaining({
5324+
channel: "discord",
5325+
conversationId: "1510164477642014740",
5326+
parentConversationId: "channel:1510164477642014999",
5327+
}),
5328+
expect.objectContaining({
5329+
conversationId: "1510164477642014740",
5330+
parentConversationId: "channel:1510164477642014999",
5331+
pluginBinding: expect.objectContaining({ bindingId: "binding-discord-thread" }),
5332+
}),
5333+
);
5334+
expect(replyResolver).not.toHaveBeenCalled();
5335+
});
5336+
52135337
it("does not run plugin-owned binding delivery when the caller already aborted", async () => {
52145338
setNoAbort();
52155339
hookMocks.runner.hasHooks.mockImplementation(

src/channels/conversation-resolution.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ type ResolveInboundConversationResolutionInput = {
6969
accountId?: string | null;
7070
to?: string | null;
7171
threadId?: string | number | null;
72+
threadParentId?: string | number | null;
7273
conversationId?: string | null;
7374
groupId?: string | null;
7475
from?: string | null;
@@ -422,6 +423,7 @@ export function resolveInboundConversationResolution(
422423
normalizeOptionalString(params.groupId) ??
423424
normalizeOptionalString(params.to),
424425
threadId,
426+
threadParentId: stringifyRouteThreadId(params.threadParentId),
425427
isGroup: params.isGroup ?? true,
426428
};
427429

@@ -455,6 +457,11 @@ export function resolveInboundConversationResolution(
455457
}
456458

457459
const parentConversationId =
460+
resolveChannelTargetId({
461+
channel,
462+
target: params.threadParentId == null ? undefined : String(params.threadParentId),
463+
preserveExplicitTopicSuffix: threadId == null,
464+
}) ??
458465
resolveChannelTargetId({
459466
channel,
460467
target: params.to,

0 commit comments

Comments
 (0)