fix: move bootstrap session grammar into plugins

This commit is contained in:
Gustavo Madeira Santana
2026-03-31 13:35:16 -04:00
parent c6b3d134f9
commit c3c3432b33
11 changed files with 159 additions and 62 deletions

View File

@@ -40,6 +40,11 @@ in the plugin with `messaging.resolveSessionConversation(...)`. That is the
canonical hook for mapping `rawId` to the base conversation id, optional thread
id, and any `parentConversationCandidates`.
Bundled plugins that need the same parsing before the channel registry boots
can also expose a top-level `session-key-api.ts` file with a matching
`resolveSessionConversation(...)` export. Core uses that bootstrap-safe surface
only when the runtime plugin registry is not available yet.
`messaging.resolveParentConversationCandidates(...)` remains available as a
legacy compatibility fallback when a plugin only needs parent fallbacks on top
of the generic/raw id. If both hooks exist, core uses

View File

@@ -0,0 +1 @@
export { resolveFeishuSessionConversation as resolveSessionConversation } from "./src/session-conversation.js";

View File

@@ -55,6 +55,10 @@ import {
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { getFeishuRuntime } from "./runtime.js";
import {
resolveFeishuParentConversationCandidates,
resolveFeishuSessionConversation,
} from "./session-conversation.js";
import { resolveFeishuOutboundSessionRoute } from "./session-route.js";
import { feishuSetupAdapter } from "./setup-core.js";
import { feishuSetupWizard } from "./setup-surface.js";
@@ -275,43 +279,6 @@ function normalizeFeishuAcpConversationId(conversationId: string) {
};
}
function resolveFeishuParentConversationCandidates(rawId: string): string[] {
const parsed = parseFeishuConversationId({ conversationId: rawId });
if (!parsed) {
return [];
}
switch (parsed.scope) {
case "group_topic_sender":
return [
buildFeishuConversationId({
chatId: parsed.chatId,
scope: "group_topic",
topicId: parsed.topicId,
}),
parsed.chatId,
];
case "group_topic":
case "group_sender":
return [parsed.chatId];
case "group":
default:
return [];
}
}
function resolveFeishuSessionConversation(rawId: string) {
const parsed = parseFeishuConversationId({ conversationId: rawId });
if (!parsed) {
return null;
}
return {
id: parsed.canonicalConversationId,
parentConversationCandidates: resolveFeishuParentConversationCandidates(
parsed.canonicalConversationId,
),
};
}
function matchFeishuAcpConversation(params: {
bindingConversationId: string;
conversationId: string;
@@ -1105,7 +1072,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
setupWizard: feishuSetupWizard,
messaging: {
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
resolveSessionConversation: ({ rawId }) => resolveFeishuSessionConversation(rawId),
resolveSessionConversation: ({ kind, rawId }) =>
resolveFeishuSessionConversation({ kind, rawId }),
resolveOutboundSessionRoute: (params) => resolveFeishuOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeFeishuId,

View File

@@ -0,0 +1,41 @@
import { buildFeishuConversationId, parseFeishuConversationId } from "./conversation-id.js";
export function resolveFeishuParentConversationCandidates(rawId: string): string[] {
const parsed = parseFeishuConversationId({ conversationId: rawId });
if (!parsed) {
return [];
}
switch (parsed.scope) {
case "group_topic_sender":
return [
buildFeishuConversationId({
chatId: parsed.chatId,
scope: "group_topic",
topicId: parsed.topicId,
}),
parsed.chatId,
];
case "group_topic":
case "group_sender":
return [parsed.chatId];
case "group":
default:
return [];
}
}
export function resolveFeishuSessionConversation(params: {
kind: "group" | "channel";
rawId: string;
}) {
const parsed = parseFeishuConversationId({ conversationId: params.rawId });
if (!parsed) {
return null;
}
return {
id: parsed.canonicalConversationId,
parentConversationCandidates: resolveFeishuParentConversationCandidates(
parsed.canonicalConversationId,
),
};
}

View File

@@ -0,0 +1 @@
export { resolveTelegramSessionConversation as resolveSessionConversation } from "./src/session-conversation.js";

View File

@@ -72,6 +72,7 @@ import type { TelegramProbe } from "./probe.js";
import { resolveTelegramReactionLevel } from "./reaction-level.js";
import { getTelegramRuntime } from "./runtime.js";
import { sendMessageTelegram, sendPollTelegram, sendTypingTelegram } from "./send.js";
import { resolveTelegramSessionConversation } from "./session-conversation.js";
import { telegramSetupAdapter } from "./setup-core.js";
import { telegramSetupWizard } from "./setup-surface.js";
import {
@@ -232,18 +233,6 @@ function normalizeTelegramAcpConversationId(conversationId: string) {
};
}
function resolveTelegramSessionConversation(rawId: string) {
const parsed = parseTelegramTopicConversation({ conversationId: rawId });
if (!parsed) {
return null;
}
return {
id: parsed.chatId,
threadId: parsed.topicId,
parentConversationCandidates: [parsed.chatId],
};
}
function matchTelegramAcpConversation(params: {
bindingConversationId: string;
conversationId: string;
@@ -542,7 +531,8 @@ export const telegramPlugin = createChatChannelPlugin({
},
messaging: {
normalizeTarget: normalizeTelegramMessagingTarget,
resolveSessionConversation: ({ rawId }) => resolveTelegramSessionConversation(rawId),
resolveSessionConversation: ({ kind, rawId }) =>
resolveTelegramSessionConversation({ kind, rawId }),
parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw),
inferTargetChatType: ({ to }) => parseTelegramExplicitTarget(to).chatType,
formatTargetDisplay: ({ target, display, kind }) => {

View File

@@ -0,0 +1,16 @@
import { parseTelegramTopicConversation } from "openclaw/plugin-sdk/telegram-core";
export function resolveTelegramSessionConversation(params: {
kind: "group" | "channel";
rawId: string;
}) {
const parsed = parseTelegramTopicConversation({ conversationId: params.rawId });
if (!parsed) {
return null;
}
return {
id: parsed.chatId,
threadId: parsed.topicId,
parentConversationCandidates: [parsed.chatId],
};
}

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js";
import { resolveChannelModelOverride } from "./model-overrides.js";
@@ -167,4 +167,27 @@ describe("resolveChannelModelOverride", () => {
expect(resolved?.model).toBe("demo-provider/demo-channel-model");
expect(resolved?.matchKey).toBe("thread-parent");
});
it("keeps bundled Feishu parent fallback matching before registry bootstrap", () => {
resetPluginRuntimeStateForTest();
const resolved = resolveChannelModelOverride({
cfg: {
channels: {
modelByChannel: {
feishu: {
"oc_group_chat:topic:om_topic_root": "demo-provider/demo-feishu-topic-model",
},
},
},
} as unknown as OpenClawConfig,
channel: "feishu",
groupId: "unrelated",
parentSessionKey:
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
});
expect(resolved?.model).toBe("demo-provider/demo-feishu-topic-model");
expect(resolved?.matchKey).toBe("oc_group_chat:topic:om_topic_root");
});
});

View File

@@ -61,6 +61,25 @@ describe("session conversation routing", () => {
});
});
it("keeps bundled Feishu parent fallbacks available before registry bootstrap", () => {
resetPluginRuntimeStateForTest();
expect(
resolveSessionConversationRef(
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
),
).toEqual({
channel: "feishu",
kind: "group",
rawId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
id: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
threadId: undefined,
baseSessionKey:
"agent:main:feishu:group:oc_group_chat:topic:om_topic_root:sender:ou_topic_user",
parentConversationCandidates: ["oc_group_chat:topic:om_topic_root", "oc_group_chat"],
});
});
it("lets Feishu own parent fallback candidates", () => {
expect(
resolveSessionConversationRef(

View File

@@ -1,4 +1,7 @@
import { parseTelegramTopicConversation } from "../../acp/conversation-id.js";
import { fileURLToPath } from "node:url";
import { loadBundledPluginPublicSurfaceModuleSync } from "../../plugin-sdk/facade-runtime.js";
import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js";
import { resolveBundledPluginPublicSurfacePath } from "../../plugins/bundled-plugin-metadata.js";
import {
parseRawSessionConversationRef,
parseThreadSessionSuffix,
@@ -30,6 +33,20 @@ type SessionConversationHookResult = {
parentConversationCandidates?: string[];
};
type SessionConversationResolverParams = {
kind: "group" | "channel";
rawId: string;
};
type BundledSessionKeyModule = {
resolveSessionConversation?: (
params: SessionConversationResolverParams,
) => SessionConversationHookResult | null;
};
const OPENCLAW_PACKAGE_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
const SESSION_KEY_API_ARTIFACT_BASENAME = "session-key-api.js";
type NormalizedSessionConversationResolution = ResolvedSessionConversation & {
hasExplicitParentConversationCandidates: boolean;
};
@@ -111,23 +128,36 @@ function normalizeSessionConversationResolution(
function resolveBundledSessionConversationFallback(params: {
channel: string;
kind: "group" | "channel";
rawId: string;
}): NormalizedSessionConversationResolution | null {
if (normalizeResolvedChannel(params.channel) !== "telegram") {
const dirName = normalizeResolvedChannel(params.channel);
if (
!resolveBundledPluginPublicSurfacePath({
rootDir: OPENCLAW_PACKAGE_ROOT,
bundledPluginsDir: resolveBundledPluginsDir(),
dirName,
artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME,
})
) {
return null;
}
const parsed = parseTelegramTopicConversation({ conversationId: params.rawId });
if (!parsed) {
const resolveSessionConversation =
loadBundledPluginPublicSurfaceModuleSync<BundledSessionKeyModule>({
dirName,
artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME,
}).resolveSessionConversation;
if (typeof resolveSessionConversation !== "function") {
return null;
}
return {
id: parsed.chatId,
threadId: parsed.topicId,
parentConversationCandidates: [parsed.chatId],
hasExplicitParentConversationCandidates: true,
};
return normalizeSessionConversationResolution(
resolveSessionConversation({
kind: params.kind,
rawId: params.rawId,
}),
);
}
function resolveSessionConversationResolution(params: {
@@ -151,6 +181,7 @@ function resolveSessionConversationResolution(params: {
pluginResolved ??
resolveBundledSessionConversationFallback({
channel: params.channel,
kind: params.kind,
rawId,
}) ??
buildGenericConversationResolution(rawId);

View File

@@ -403,6 +403,8 @@ export type ChannelMessagingAdapter = {
* inside `rawId` (for example Telegram topics or Feishu sender scopes).
* Return `parentConversationCandidates` here when you can so parsing and
* inheritance stay in one place.
* Bundled plugins that need the same grammar before runtime bootstrap can
* mirror this contract through a top-level `session-key-api.ts` surface.
*/
resolveSessionConversation?: (params: { kind: "group" | "channel"; rawId: string }) => {
id: string;