mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
refactor(channels): centralize runtime binding routes
This commit is contained in:
@@ -1,14 +1,9 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
isPluginOwnedSessionBindingRecord,
|
||||
resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
deriveLastRoutePolicy,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentRoute,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveBlueBubblesInboundConversationId } from "./conversation-id.js";
|
||||
|
||||
@@ -53,31 +48,21 @@ export function resolveBlueBubblesConversationRoute(params: {
|
||||
},
|
||||
}).route;
|
||||
|
||||
const runtimeBinding = getSessionBindingService().resolveByConversation({
|
||||
channel: "bluebubbles",
|
||||
accountId: params.accountId,
|
||||
conversationId,
|
||||
const runtimeRoute = resolveRuntimeConversationBindingRoute({
|
||||
route,
|
||||
conversation: {
|
||||
channel: "bluebubbles",
|
||||
accountId: params.accountId,
|
||||
conversationId,
|
||||
},
|
||||
});
|
||||
const boundSessionKey = runtimeBinding?.targetSessionKey?.trim();
|
||||
if (!runtimeBinding || !boundSessionKey) {
|
||||
return route;
|
||||
}
|
||||
|
||||
getSessionBindingService().touch(runtimeBinding.bindingId);
|
||||
if (isPluginOwnedSessionBindingRecord(runtimeBinding)) {
|
||||
route = runtimeRoute.route;
|
||||
if (runtimeRoute.bindingRecord && !runtimeRoute.boundSessionKey) {
|
||||
logVerbose(`bluebubbles: plugin-bound conversation ${conversationId}`);
|
||||
return route;
|
||||
} else if (runtimeRoute.boundSessionKey) {
|
||||
logVerbose(
|
||||
`bluebubbles: routed via bound conversation ${conversationId} -> ${runtimeRoute.boundSessionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
logVerbose(`bluebubbles: routed via bound conversation ${conversationId} -> ${boundSessionKey}`);
|
||||
return {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,50 @@ export function installAgentContractHooks() {
|
||||
installBrowserControlServerHooks();
|
||||
}
|
||||
|
||||
function isTransientStartupFetchError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = error as { code?: unknown; cause?: unknown };
|
||||
if (record.code === "ECONNRESET" || record.code === "ECONNREFUSED") {
|
||||
return true;
|
||||
}
|
||||
return isTransientStartupFetchError(record.cause);
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function postStartWithRetry(params: {
|
||||
fetch: ReturnType<typeof getBrowserTestFetch>;
|
||||
url: string;
|
||||
}): Promise<void> {
|
||||
const delaysMs = [0, 25, 50, 100, 200] as const;
|
||||
let lastError: unknown;
|
||||
for (const delayMs of delaysMs) {
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
try {
|
||||
const response = await params.fetch(params.url, { method: "POST" });
|
||||
await response.json();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStartupFetchError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function startServerAndBase(): Promise<string> {
|
||||
await startBrowserControlServerFromConfig();
|
||||
const base = getBrowserControlServerBaseUrl();
|
||||
const realFetch = getBrowserTestFetch();
|
||||
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
|
||||
await postStartWithRetry({ fetch: realFetch, url: `${base}/start` });
|
||||
return base;
|
||||
}
|
||||
|
||||
|
||||
@@ -629,13 +629,16 @@ export async function preflightDiscordMessage(
|
||||
}) ?? `user:${author.id}`)
|
||||
: messageChannelId;
|
||||
let threadBinding: SessionBindingRecord | undefined;
|
||||
threadBinding =
|
||||
conversationRuntime.getSessionBindingService().resolveByConversation({
|
||||
const runtimeRoute = conversationRuntime.resolveRuntimeConversationBindingRoute({
|
||||
route,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: bindingConversationId,
|
||||
parentConversationId: earlyThreadParentId,
|
||||
}) ?? undefined;
|
||||
},
|
||||
});
|
||||
threadBinding = runtimeRoute.bindingRecord ?? undefined;
|
||||
const configuredRoute =
|
||||
threadBinding == null
|
||||
? conversationRuntime.resolveConfiguredBindingRoute({
|
||||
@@ -666,13 +669,15 @@ export async function preflightDiscordMessage(
|
||||
}
|
||||
const boundSessionKey = conversationRuntime.isPluginOwnedSessionBindingRecord(threadBinding)
|
||||
? ""
|
||||
: threadBinding?.targetSessionKey?.trim();
|
||||
const effectiveRoute = resolveDiscordEffectiveRoute({
|
||||
route,
|
||||
boundSessionKey,
|
||||
configuredRoute,
|
||||
matchedBy: "binding.channel",
|
||||
});
|
||||
: (runtimeRoute.boundSessionKey ?? threadBinding?.targetSessionKey?.trim());
|
||||
const effectiveRoute = runtimeRoute.boundSessionKey
|
||||
? runtimeRoute.route
|
||||
: resolveDiscordEffectiveRoute({
|
||||
route,
|
||||
boundSessionKey,
|
||||
configuredRoute,
|
||||
matchedBy: "binding.channel",
|
||||
});
|
||||
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
||||
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
|
||||
const bypassMentionRequirement = isBoundThreadSession;
|
||||
|
||||
@@ -262,7 +262,7 @@ const {
|
||||
mockEnsureConfiguredBindingRouteReady: vi.fn(
|
||||
async (_params?: unknown): Promise<BindingReadiness> => ({ ok: true }),
|
||||
),
|
||||
mockResolveBoundConversation: vi.fn(() => null as BoundConversation),
|
||||
mockResolveBoundConversation: vi.fn((_ref?: unknown) => null as BoundConversation),
|
||||
mockTouchBinding: vi.fn(),
|
||||
mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
|
||||
}));
|
||||
@@ -297,6 +297,30 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
...actual,
|
||||
resolveConfiguredBindingRoute: (params: unknown) =>
|
||||
mockResolveConfiguredBindingRoute(params as { route: ResolvedAgentRoute }),
|
||||
resolveRuntimeConversationBindingRoute: (params: {
|
||||
route: ResolvedAgentRoute;
|
||||
conversation: Parameters<
|
||||
ReturnType<typeof actual.getSessionBindingService>["resolveByConversation"]
|
||||
>[0];
|
||||
}) => {
|
||||
const bindingRecord = mockResolveBoundConversation(params.conversation);
|
||||
const boundSessionKey = bindingRecord?.targetSessionKey?.trim();
|
||||
if (!bindingRecord || !boundSessionKey) {
|
||||
return { bindingRecord: null, route: params.route };
|
||||
}
|
||||
mockTouchBinding(bindingRecord.bindingId);
|
||||
return {
|
||||
bindingRecord,
|
||||
boundSessionKey,
|
||||
boundAgentId: params.route.agentId,
|
||||
route: {
|
||||
...params.route,
|
||||
sessionKey: boundSessionKey,
|
||||
lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
};
|
||||
},
|
||||
ensureConfiguredBindingRouteReady: (params: unknown) =>
|
||||
mockEnsureConfiguredBindingRouteReady(params),
|
||||
getSessionBindingService: () => ({
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pair
|
||||
import {
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
import { deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
@@ -651,28 +649,22 @@ export async function handleFeishuMessage(params: {
|
||||
// Bound Feishu conversations intentionally require an exact live conversation-id match.
|
||||
// Sender-scoped topic sessions therefore bind on `chat:topic:root:sender:user`, while
|
||||
// configured ACP bindings may still inherit the shared `chat:topic:root` topic session.
|
||||
const threadBinding = getSessionBindingService().resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
const runtimeRoute = resolveRuntimeConversationBindingRoute({
|
||||
route,
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
conversationId: currentConversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
},
|
||||
});
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
if (threadBinding && boundSessionKey) {
|
||||
route = {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
route = runtimeRoute.route;
|
||||
if (runtimeRoute.bindingRecord) {
|
||||
configuredBinding = null;
|
||||
getSessionBindingService().touch(threadBinding.bindingId);
|
||||
log(
|
||||
`feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${boundSessionKey}`,
|
||||
runtimeRoute.boundSessionKey
|
||||
? `feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${runtimeRoute.boundSessionKey}`
|
||||
: `feishu[${account.accountId}]: plugin-bound conversation ${currentConversationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ type FeishuLifecycleTestMocks = {
|
||||
monitorWebhookMock: AsyncUnknownMock;
|
||||
createFeishuThreadBindingManagerMock: UnknownMock;
|
||||
createFeishuReplyDispatcherMock: CreateFeishuReplyDispatcherMock;
|
||||
resolveBoundConversationMock: Mock<() => BoundConversation | null>;
|
||||
resolveBoundConversationMock: Mock<(ref?: unknown) => BoundConversation | null>;
|
||||
touchBindingMock: UnknownMock;
|
||||
resolveAgentRouteMock: UnknownMock;
|
||||
resolveConfiguredBindingRouteMock: UnknownMock;
|
||||
@@ -66,7 +66,7 @@ const feishuLifecycleTestMocks = vi.hoisted(
|
||||
monitorWebhookMock: vi.fn(async () => {}),
|
||||
createFeishuThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })),
|
||||
createFeishuReplyDispatcherMock: vi.fn(),
|
||||
resolveBoundConversationMock: vi.fn<() => BoundConversation | null>(() => null),
|
||||
resolveBoundConversationMock: vi.fn<(ref?: unknown) => BoundConversation | null>(() => null),
|
||||
touchBindingMock: vi.fn(),
|
||||
resolveAgentRouteMock: vi.fn(),
|
||||
resolveConfiguredBindingRouteMock: vi.fn(),
|
||||
@@ -155,6 +155,36 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
resolveConfiguredBindingRouteMock.getMockImplementation()
|
||||
? resolveConfiguredBindingRouteMock(params)
|
||||
: actual.resolveConfiguredBindingRoute(params),
|
||||
resolveRuntimeConversationBindingRoute: (
|
||||
params: Parameters<typeof actual.resolveRuntimeConversationBindingRoute>[0],
|
||||
) => {
|
||||
const conversation =
|
||||
"conversation" in params
|
||||
? params.conversation
|
||||
: {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
};
|
||||
const bindingRecord = resolveBoundConversationMock(conversation);
|
||||
const boundSessionKey = bindingRecord?.targetSessionKey?.trim();
|
||||
if (!bindingRecord || !boundSessionKey) {
|
||||
return { bindingRecord: null, route: params.route };
|
||||
}
|
||||
touchBindingMock(bindingRecord.bindingId);
|
||||
return {
|
||||
bindingRecord,
|
||||
boundSessionKey,
|
||||
boundAgentId: params.route.agentId,
|
||||
route: {
|
||||
...params.route,
|
||||
sessionKey: boundSessionKey,
|
||||
lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
};
|
||||
},
|
||||
ensureConfiguredBindingRouteReady: (
|
||||
params: Parameters<typeof actual.ensureConfiguredBindingRouteReady>[0],
|
||||
) =>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
isPluginOwnedSessionBindingRecord,
|
||||
resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
deriveLastRoutePolicy,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentRoute,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveIMessageInboundConversationId } from "./conversation-id.js";
|
||||
|
||||
@@ -49,31 +44,21 @@ export function resolveIMessageConversationRoute(params: {
|
||||
},
|
||||
}).route;
|
||||
|
||||
const runtimeBinding = getSessionBindingService().resolveByConversation({
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
conversationId,
|
||||
const runtimeRoute = resolveRuntimeConversationBindingRoute({
|
||||
route,
|
||||
conversation: {
|
||||
channel: "imessage",
|
||||
accountId: params.accountId,
|
||||
conversationId,
|
||||
},
|
||||
});
|
||||
const boundSessionKey = runtimeBinding?.targetSessionKey?.trim();
|
||||
if (!runtimeBinding || !boundSessionKey) {
|
||||
return route;
|
||||
}
|
||||
|
||||
getSessionBindingService().touch(runtimeBinding.bindingId);
|
||||
if (isPluginOwnedSessionBindingRecord(runtimeBinding)) {
|
||||
route = runtimeRoute.route;
|
||||
if (runtimeRoute.bindingRecord && !runtimeRoute.boundSessionKey) {
|
||||
logVerbose(`imessage: plugin-bound conversation ${conversationId}`);
|
||||
return route;
|
||||
} else if (runtimeRoute.boundSessionKey) {
|
||||
logVerbose(
|
||||
`imessage: routed via bound conversation ${conversationId} -> ${runtimeRoute.boundSessionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
logVerbose(`imessage: routed via bound conversation ${conversationId} -> ${boundSessionKey}`);
|
||||
return {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -8,19 +8,15 @@ import {
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
ensureConfiguredBindingRouteReady,
|
||||
getSessionBindingService,
|
||||
recordInboundSession,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import {
|
||||
deriveLastRoutePolicy,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentRoute,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
@@ -132,26 +128,22 @@ async function resolveLineInboundRoute(params: {
|
||||
const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
|
||||
route = configuredRoute.route;
|
||||
|
||||
const boundConversation = getSessionBindingService().resolveByConversation({
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
conversationId: peerId,
|
||||
const runtimeRoute = resolveRuntimeConversationBindingRoute({
|
||||
route,
|
||||
conversation: {
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
conversationId: peerId,
|
||||
},
|
||||
});
|
||||
const boundSessionKey = boundConversation?.targetSessionKey?.trim();
|
||||
if (boundConversation && boundSessionKey) {
|
||||
route = {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
route = runtimeRoute.route;
|
||||
if (runtimeRoute.bindingRecord) {
|
||||
configuredBinding = null;
|
||||
getSessionBindingService().touch(boundConversation.bindingId);
|
||||
logVerbose(`line: routed via bound conversation ${peerId} -> ${boundSessionKey}`);
|
||||
logVerbose(
|
||||
runtimeRoute.boundSessionKey
|
||||
? `line: routed via bound conversation ${peerId} -> ${runtimeRoute.boundSessionKey}`
|
||||
: `line: plugin-bound conversation ${peerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (configuredBinding) {
|
||||
|
||||
@@ -71,6 +71,36 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute: (
|
||||
params: Parameters<typeof actual.resolveRuntimeConversationBindingRoute>[0],
|
||||
) => {
|
||||
const conversation =
|
||||
"conversation" in params
|
||||
? params.conversation
|
||||
: {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
};
|
||||
const bindingRecord = sessionBindingMocks.resolveByConversation(conversation);
|
||||
const boundSessionKey = bindingRecord?.targetSessionKey?.trim();
|
||||
if (!bindingRecord || !boundSessionKey) {
|
||||
return { bindingRecord: null, route: params.route };
|
||||
}
|
||||
sessionBindingMocks.touch(bindingRecord.bindingId, undefined);
|
||||
return {
|
||||
bindingRecord,
|
||||
boundSessionKey,
|
||||
boundAgentId: params.route.agentId,
|
||||
route: {
|
||||
...params.route,
|
||||
sessionKey: boundSessionKey,
|
||||
lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session",
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
};
|
||||
},
|
||||
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady,
|
||||
recordInboundSessionMetaSafe: vi.fn(
|
||||
async (params: {
|
||||
|
||||
@@ -108,6 +108,10 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
|
||||
bindingResolution: null,
|
||||
boundSessionKey: "",
|
||||
})),
|
||||
resolveRuntimeConversationBindingRoute: vi.fn(({ route }: { route: unknown }) => ({
|
||||
bindingRecord: null,
|
||||
route,
|
||||
})),
|
||||
getSessionBindingService: vi.fn(() => ({
|
||||
resolveByConversation: vi.fn(() => null),
|
||||
touch: vi.fn(),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
type ConfiguredBindingRouteResult,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
buildAgentSessionKey,
|
||||
deriveLastRoutePolicy,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
resolveAgentIdFromSessionKey,
|
||||
sanitizeAgentId,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -116,32 +114,22 @@ export function resolveTelegramConversationRoute(params: {
|
||||
? String(params.chatId)
|
||||
: undefined;
|
||||
if (threadBindingConversationId) {
|
||||
const threadBinding = getSessionBindingService().resolveByConversation({
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
conversationId: threadBindingConversationId,
|
||||
const runtimeRoute = resolveRuntimeConversationBindingRoute({
|
||||
route,
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: params.accountId,
|
||||
conversationId: threadBindingConversationId,
|
||||
},
|
||||
});
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
if (threadBinding && boundSessionKey) {
|
||||
if (!isPluginOwnedSessionBindingRecord(threadBinding)) {
|
||||
route = {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey),
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
}
|
||||
route = runtimeRoute.route;
|
||||
if (runtimeRoute.bindingRecord) {
|
||||
configuredBinding = null;
|
||||
configuredBindingSessionKey = "";
|
||||
getSessionBindingService().touch(threadBinding.bindingId);
|
||||
logVerbose(
|
||||
isPluginOwnedSessionBindingRecord(threadBinding)
|
||||
? `telegram: plugin-bound conversation ${threadBindingConversationId}`
|
||||
: `telegram: routed via bound conversation ${threadBindingConversationId} -> ${boundSessionKey}`,
|
||||
runtimeRoute.boundSessionKey
|
||||
? `telegram: routed via bound conversation ${threadBindingConversationId} -> ${runtimeRoute.boundSessionKey}`
|
||||
: `telegram: plugin-bound conversation ${threadBindingConversationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
114
src/channels/plugins/binding-routing.test.ts
Normal file
114
src/channels/plugins/binding-routing.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__testing,
|
||||
registerSessionBindingAdapter,
|
||||
type SessionBindingAdapter,
|
||||
type SessionBindingRecord,
|
||||
} from "../../infra/outbound/session-binding-service.js";
|
||||
import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { resolveRuntimeConversationBindingRoute } from "./binding-routing.js";
|
||||
|
||||
function createRoute(): ResolvedAgentRoute {
|
||||
return {
|
||||
agentId: "main",
|
||||
channel: "demo",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:main",
|
||||
mainSessionKey: "agent:main:main",
|
||||
lastRoutePolicy: "main",
|
||||
matchedBy: "default",
|
||||
};
|
||||
}
|
||||
|
||||
function createBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
|
||||
return {
|
||||
bindingId: "binding-1",
|
||||
targetSessionKey: "agent:review:acp:session-1",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "demo",
|
||||
accountId: "default",
|
||||
conversationId: "room-1",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function registerAdapter(record: SessionBindingRecord | null): {
|
||||
resolveByConversation: ReturnType<typeof vi.fn>;
|
||||
touch: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const resolveByConversation = vi.fn<SessionBindingAdapter["resolveByConversation"]>(() => record);
|
||||
const touch = vi.fn<NonNullable<SessionBindingAdapter["touch"]>>();
|
||||
registerSessionBindingAdapter({
|
||||
channel: "demo",
|
||||
accountId: "default",
|
||||
listBySession: () => [],
|
||||
resolveByConversation,
|
||||
touch,
|
||||
});
|
||||
return { resolveByConversation, touch };
|
||||
}
|
||||
|
||||
describe("runtime conversation binding route", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetSessionBindingAdaptersForTests();
|
||||
});
|
||||
|
||||
it("rewrites the route to a runtime-bound ACP session and touches the binding", () => {
|
||||
const binding = createBinding();
|
||||
const { resolveByConversation, touch } = registerAdapter(binding);
|
||||
|
||||
const result = resolveRuntimeConversationBindingRoute({
|
||||
route: createRoute(),
|
||||
conversation: {
|
||||
channel: "demo",
|
||||
accountId: "default",
|
||||
conversationId: "room-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveByConversation).toHaveBeenCalledWith({
|
||||
channel: "demo",
|
||||
accountId: "default",
|
||||
conversationId: "room-1",
|
||||
});
|
||||
expect(touch).toHaveBeenCalledWith("binding-1", undefined);
|
||||
expect(result.boundSessionKey).toBe("agent:review:acp:session-1");
|
||||
expect(result.boundAgentId).toBe("review");
|
||||
expect(result.route).toMatchObject({
|
||||
agentId: "review",
|
||||
sessionKey: "agent:review:acp:session-1",
|
||||
lastRoutePolicy: "session",
|
||||
matchedBy: "binding.channel",
|
||||
});
|
||||
});
|
||||
|
||||
it("touches plugin-owned bindings without rewriting the channel route", () => {
|
||||
const route = createRoute();
|
||||
const binding = createBinding({
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "demo-plugin",
|
||||
pluginRoot: "/tmp/demo-plugin",
|
||||
},
|
||||
});
|
||||
const { touch } = registerAdapter(binding);
|
||||
|
||||
const result = resolveRuntimeConversationBindingRoute({
|
||||
route,
|
||||
conversation: {
|
||||
channel: "demo",
|
||||
accountId: "default",
|
||||
conversationId: "room-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(touch).toHaveBeenCalledWith("binding-1", undefined);
|
||||
expect(result.bindingRecord).toBe(binding);
|
||||
expect(result.boundSessionKey).toBeUndefined();
|
||||
expect(result.route).toBe(route);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from "node:path";
|
||||
import { providerOpenAiExtensionTestRoots } from "./vitest.extension-provider-paths.mjs";
|
||||
import { loadPatternListFromEnv } from "./vitest.pattern-file.ts";
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
import { repoRoot } from "./vitest.shared.config.ts";
|
||||
|
||||
export function loadIncludePatternsFromEnv(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
@@ -11,7 +13,7 @@ export function loadIncludePatternsFromEnv(
|
||||
export function createExtensionProviderOpenAiVitestConfig(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
) {
|
||||
return createScopedVitestConfig(
|
||||
const config = createScopedVitestConfig(
|
||||
loadIncludePatternsFromEnv(env) ??
|
||||
providerOpenAiExtensionTestRoots.map((root) => `${root}/**/*.test.ts`),
|
||||
{
|
||||
@@ -22,6 +24,19 @@ export function createExtensionProviderOpenAiVitestConfig(
|
||||
setupFiles: ["test/setup.extensions.ts"],
|
||||
},
|
||||
);
|
||||
return {
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
alias: [
|
||||
...(Array.isArray(config.resolve?.alias) ? config.resolve.alias : []),
|
||||
{
|
||||
find: /^ws$/u,
|
||||
replacement: path.join(repoRoot, "node_modules", "ws", "wrapper.mjs"),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default createExtensionProviderOpenAiVitestConfig();
|
||||
|
||||
Reference in New Issue
Block a user