mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-26 00:21:59 +00:00
209 lines
5.9 KiB
TypeScript
209 lines
5.9 KiB
TypeScript
import { ChannelType } from "@buape/carbon";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const loadConfigMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
|
return {
|
|
...actual,
|
|
loadConfig: () => loadConfigMock(),
|
|
};
|
|
});
|
|
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
|
import {
|
|
getSessionBindingService,
|
|
registerSessionBindingAdapter,
|
|
type SessionBindingBindInput,
|
|
type SessionBindingRecord,
|
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { __testing as sessionBindingTesting } from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { preflightDiscordMessage } from "./message-handler.preflight.js";
|
|
import {
|
|
createDiscordMessage,
|
|
createDiscordPreflightArgs,
|
|
type DiscordClient,
|
|
type DiscordConfig,
|
|
type DiscordMessageEvent,
|
|
} from "./message-handler.preflight.test-helpers.js";
|
|
|
|
const baseCfg = {
|
|
session: {
|
|
mainKey: "main",
|
|
scope: "per-sender",
|
|
},
|
|
acp: {
|
|
enabled: true,
|
|
dispatch: {
|
|
enabled: true,
|
|
},
|
|
backend: "acpx",
|
|
},
|
|
channels: {
|
|
discord: {
|
|
threadBindings: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
function createDmClient(channelId: string): DiscordClient {
|
|
return {
|
|
fetchChannel: async (id: string) => {
|
|
if (id === channelId) {
|
|
return {
|
|
id: channelId,
|
|
type: ChannelType.DM,
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
} as unknown as DiscordClient;
|
|
}
|
|
|
|
function createInMemoryDiscordBindingAdapter() {
|
|
const bindings: SessionBindingRecord[] = [];
|
|
|
|
const bind = async (input: SessionBindingBindInput) => {
|
|
const normalizedConversation = {
|
|
...input.conversation,
|
|
parentConversationId:
|
|
input.conversation.parentConversationId ??
|
|
(input.placement === "current" ? input.conversation.conversationId : undefined),
|
|
};
|
|
const record = {
|
|
bindingId: `discord:default:${normalizedConversation.conversationId}`,
|
|
targetSessionKey: input.targetSessionKey,
|
|
targetKind: input.targetKind,
|
|
conversation: normalizedConversation,
|
|
status: "active",
|
|
boundAt: 1,
|
|
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
} satisfies SessionBindingRecord;
|
|
const existingIndex = bindings.findIndex((entry) => entry.bindingId === record.bindingId);
|
|
if (existingIndex >= 0) {
|
|
bindings.splice(existingIndex, 1, record);
|
|
} else {
|
|
bindings.push(record);
|
|
}
|
|
return record;
|
|
};
|
|
|
|
registerSessionBindingAdapter({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
capabilities: {
|
|
placements: ["current", "child"],
|
|
bindSupported: true,
|
|
unbindSupported: true,
|
|
},
|
|
bind,
|
|
listBySession: (targetSessionKey) =>
|
|
bindings.filter((entry) => entry.targetSessionKey === targetSessionKey),
|
|
resolveByConversation: (ref) =>
|
|
bindings.find(
|
|
(entry) =>
|
|
entry.conversation.channel === ref.channel &&
|
|
entry.conversation.accountId === ref.accountId &&
|
|
entry.conversation.conversationId === ref.conversationId,
|
|
) ?? null,
|
|
unbind: async ({ bindingId, targetSessionKey }) => {
|
|
const removed = bindings.filter(
|
|
(entry) =>
|
|
(bindingId && entry.bindingId === bindingId) ||
|
|
(targetSessionKey && entry.targetSessionKey === targetSessionKey),
|
|
);
|
|
for (const entry of removed) {
|
|
const index = bindings.findIndex((candidate) => candidate.bindingId === entry.bindingId);
|
|
if (index >= 0) {
|
|
bindings.splice(index, 1);
|
|
}
|
|
}
|
|
return removed;
|
|
},
|
|
});
|
|
|
|
return { bindings };
|
|
}
|
|
|
|
describe("Discord ACP bind here end-to-end flow", () => {
|
|
beforeEach(() => {
|
|
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
|
loadConfigMock.mockReset().mockReturnValue(baseCfg);
|
|
});
|
|
|
|
it("routes the next Discord DM turn to an existing ACP session binding", async () => {
|
|
const adapter = createInMemoryDiscordBindingAdapter();
|
|
const binding = await getSessionBindingService().bind({
|
|
targetSessionKey: "agent:codex:acp:test-session",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "user:user-1",
|
|
parentConversationId: "user:user-1",
|
|
},
|
|
placement: "current",
|
|
metadata: {
|
|
boundBy: "user-1",
|
|
agentId: "codex",
|
|
label: "codex",
|
|
},
|
|
});
|
|
|
|
expect(adapter.bindings).toHaveLength(1);
|
|
expect(binding).toMatchObject({
|
|
targetSessionKey: "agent:codex:acp:test-session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "user:user-1",
|
|
parentConversationId: "user:user-1",
|
|
},
|
|
});
|
|
expect(
|
|
getSessionBindingService().resolveByConversation({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "user:user-1",
|
|
}),
|
|
)?.toMatchObject({
|
|
targetSessionKey: binding.targetSessionKey,
|
|
});
|
|
|
|
const message = createDiscordMessage({
|
|
id: "m-followup-1",
|
|
channelId: "dm-1",
|
|
content: "follow up after bind",
|
|
author: {
|
|
id: "user-1",
|
|
bot: false,
|
|
username: "alice",
|
|
},
|
|
});
|
|
|
|
const preflight = await preflightDiscordMessage({
|
|
...createDiscordPreflightArgs({
|
|
cfg: baseCfg,
|
|
discordConfig: {
|
|
dmPolicy: "open",
|
|
} as DiscordConfig,
|
|
data: {
|
|
channel_id: "dm-1",
|
|
author: message.author,
|
|
message,
|
|
} as DiscordMessageEvent,
|
|
client: createDmClient("dm-1"),
|
|
botUserId: "bot-1",
|
|
}),
|
|
});
|
|
|
|
expect(preflight).not.toBeNull();
|
|
expect(preflight?.boundSessionKey).toBe(binding.targetSessionKey);
|
|
expect(preflight?.route.sessionKey).toBe(binding.targetSessionKey);
|
|
expect(preflight?.route.agentId).toBe("codex");
|
|
});
|
|
});
|