refactor: finish decoupling plugin sdk seams

This commit is contained in:
Peter Steinberger
2026-03-29 22:41:47 +01:00
parent 574d3c5213
commit 2e0682d930
31 changed files with 499 additions and 501 deletions

View File

@@ -1004,8 +1004,10 @@ Compatibility note:
helper is only needed by a bundled extension, keep it behind the extension's
local `api.js` or `runtime-api.js` seam instead of promoting it into
`openclaw/plugin-sdk/<extension>`.
- Channel-branded bundled bars stay private unless they are explicitly added
back to the public contract.
- New shared helper seams should be generic, not channel-branded. Shared target
parsing belongs on `openclaw/plugin-sdk/channel-targets`; channel-specific
internals stay behind the owning plugin's local `api.js` or `runtime-api.js`
seam.
- Capability-specific subpaths such as `image-generation`,
`media-understanding`, and `speech` exist because bundled/native plugins use
them today. Their presence does not by itself mean every exported helper is a

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setDefaultChannelPluginRegistryForTests } from "../../../src/commands/channel-test-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
__testing as sessionBindingTesting,
@@ -17,7 +16,6 @@ const baseCfg = {
describe("resolveBlueBubblesConversationRoute", () => {
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
setDefaultChannelPluginRegistryForTests();
});
it("lets runtime BlueBubbles conversation bindings override default routing", () => {

View File

@@ -5,7 +5,7 @@ import {
type ParsedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "openclaw/plugin-sdk/imessage-core";
} from "openclaw/plugin-sdk/channel-targets";
export type BlueBubblesService = "imessage" | "sms" | "auto";

View File

@@ -0,0 +1,50 @@
import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
export function normalizeIMessageAcpConversationId(
conversationId: string,
): { conversationId: string } | null {
const trimmed = conversationId.trim();
if (!trimmed) {
return null;
}
try {
const parsed = parseIMessageTarget(trimmed);
if (parsed.kind === "handle") {
const handle = normalizeIMessageHandle(parsed.to);
return handle ? { conversationId: handle } : null;
}
if (parsed.kind === "chat_id") {
return { conversationId: String(parsed.chatId) };
}
if (parsed.kind === "chat_guid") {
return { conversationId: parsed.chatGuid };
}
return { conversationId: parsed.chatIdentifier };
} catch {
const handle = normalizeIMessageHandle(trimmed);
return handle ? { conversationId: handle } : null;
}
}
export function matchIMessageAcpConversation(params: {
bindingConversationId: string;
conversationId: string;
}): { conversationId: string; matchPriority: number } | null {
const binding = normalizeIMessageAcpConversationId(params.bindingConversationId);
const conversation = normalizeIMessageAcpConversationId(params.conversationId);
if (!binding || !conversation) {
return null;
}
if (binding.conversationId !== conversation.conversationId) {
return null;
}
return {
conversationId: conversation.conversationId,
matchPriority: 2,
};
}
export function resolveIMessageConversationIdFromTarget(target: string): string | undefined {
return normalizeIMessageAcpConversationId(target)?.conversationId;
}

View File

@@ -2,7 +2,7 @@ import {
matchIMessageAcpConversation,
normalizeIMessageAcpConversationId,
resolveIMessageConversationIdFromTarget,
} from "openclaw/plugin-sdk/imessage-core";
} from "./conversation-id-core.js";
import { normalizeIMessageHandle } from "./targets.js";
export {

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setDefaultChannelPluginRegistryForTests } from "../../../src/commands/channel-test-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
__testing as sessionBindingTesting,
@@ -17,7 +16,6 @@ const baseCfg = {
describe("resolveIMessageConversationRoute", () => {
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
setDefaultChannelPluginRegistryForTests();
});
it("lets runtime iMessage conversation bindings override default routing", () => {

View File

@@ -1,223 +1,14 @@
import { isAllowedParsedChatSender } from "openclaw/plugin-sdk/allow-from";
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };
export type ChatTargetPrefixesParams = {
trimmed: string;
lower: string;
chatIdPrefixes: string[];
chatGuidPrefixes: string[];
chatIdentifierPrefixes: string[];
};
export type ParsedChatTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string };
export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
export type ChatSenderAllowParams = {
allowFrom: Array<string | number>;
sender: string;
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
};
function stripPrefix(value: string, prefix: string): string {
return value.slice(prefix.length).trim();
}
function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean {
return prefixes.some((prefix) => value.startsWith(prefix));
}
export function resolveServicePrefixedTarget<TService extends string, TTarget>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<ServicePrefix<TService>>;
isChatTarget: (remainderLower: string) => boolean;
parseTarget: (remainder: string) => TTarget;
}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null {
for (const { prefix, service } of params.servicePrefixes) {
if (!params.lower.startsWith(prefix)) {
continue;
}
const remainder = stripPrefix(params.trimmed, prefix);
if (!remainder) {
throw new Error(`${prefix} target is required`);
}
const remainderLower = remainder.toLowerCase();
if (params.isChatTarget(remainderLower)) {
return params.parseTarget(remainder);
}
return { kind: "handle", to: remainder, service };
}
return null;
}
export function resolveServicePrefixedChatTarget<TService extends string, TTarget>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<ServicePrefix<TService>>;
chatIdPrefixes: string[];
chatGuidPrefixes: string[];
chatIdentifierPrefixes: string[];
extraChatPrefixes?: string[];
parseTarget: (remainder: string) => TTarget;
}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null {
const chatPrefixes = [
...params.chatIdPrefixes,
...params.chatGuidPrefixes,
...params.chatIdentifierPrefixes,
...(params.extraChatPrefixes ?? []),
];
return resolveServicePrefixedTarget({
trimmed: params.trimmed,
lower: params.lower,
servicePrefixes: params.servicePrefixes,
isChatTarget: (remainderLower) => startsWithAnyPrefix(remainderLower, chatPrefixes),
parseTarget: params.parseTarget,
});
}
export function parseChatTargetPrefixesOrThrow(
params: ChatTargetPrefixesParams,
): ParsedChatTarget | null {
for (const prefix of params.chatIdPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
const chatId = Number.parseInt(value, 10);
if (!Number.isFinite(chatId)) {
throw new Error(`Invalid chat_id: ${value}`);
}
return { kind: "chat_id", chatId };
}
}
for (const prefix of params.chatGuidPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
if (!value) {
throw new Error("chat_guid is required");
}
return { kind: "chat_guid", chatGuid: value };
}
}
for (const prefix of params.chatIdentifierPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
if (!value) {
throw new Error("chat_identifier is required");
}
return { kind: "chat_identifier", chatIdentifier: value };
}
}
return null;
}
export function resolveServicePrefixedAllowTarget<TAllowTarget>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<{ prefix: string }>;
parseAllowTarget: (remainder: string) => TAllowTarget;
}): (TAllowTarget | { kind: "handle"; handle: string }) | null {
for (const { prefix } of params.servicePrefixes) {
if (!params.lower.startsWith(prefix)) {
continue;
}
const remainder = stripPrefix(params.trimmed, prefix);
if (!remainder) {
return { kind: "handle", handle: "" };
}
return params.parseAllowTarget(remainder);
}
return null;
}
export function resolveServicePrefixedOrChatAllowTarget<
TAllowTarget extends ParsedChatAllowTarget,
>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<{ prefix: string }>;
parseAllowTarget: (remainder: string) => TAllowTarget;
chatIdPrefixes: string[];
chatGuidPrefixes: string[];
chatIdentifierPrefixes: string[];
}): TAllowTarget | null {
const servicePrefixed = resolveServicePrefixedAllowTarget({
trimmed: params.trimmed,
lower: params.lower,
servicePrefixes: params.servicePrefixes,
parseAllowTarget: params.parseAllowTarget,
});
if (servicePrefixed) {
return servicePrefixed as TAllowTarget;
}
const chatTarget = parseChatAllowTargetPrefixes({
trimmed: params.trimmed,
lower: params.lower,
chatIdPrefixes: params.chatIdPrefixes,
chatGuidPrefixes: params.chatGuidPrefixes,
chatIdentifierPrefixes: params.chatIdentifierPrefixes,
});
if (chatTarget) {
return chatTarget as TAllowTarget;
}
return null;
}
export function createAllowedChatSenderMatcher<TParsed extends ParsedChatAllowTarget>(params: {
normalizeSender: (sender: string) => string;
parseAllowTarget: (entry: string) => TParsed;
}): (input: ChatSenderAllowParams) => boolean {
return (input) =>
isAllowedParsedChatSender({
allowFrom: input.allowFrom,
sender: input.sender,
chatId: input.chatId,
chatGuid: input.chatGuid,
chatIdentifier: input.chatIdentifier,
normalizeSender: params.normalizeSender,
parseAllowTarget: params.parseAllowTarget,
});
}
export function parseChatAllowTargetPrefixes(
params: ChatTargetPrefixesParams,
): ParsedChatTarget | null {
for (const prefix of params.chatIdPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
}
}
for (const prefix of params.chatGuidPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
if (value) {
return { kind: "chat_guid", chatGuid: value };
}
}
}
for (const prefix of params.chatIdentifierPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
if (value) {
return { kind: "chat_identifier", chatIdentifier: value };
}
}
}
return null;
}
export {
createAllowedChatSenderMatcher,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedChatTarget,
resolveServicePrefixedOrChatAllowTarget,
resolveServicePrefixedTarget,
type ChatSenderAllowParams,
type ChatTargetPrefixesParams,
type ParsedChatAllowTarget,
type ParsedChatTarget,
type ServicePrefix,
} from "openclaw/plugin-sdk/channel-targets";

View File

@@ -1,4 +1,4 @@
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import {
createAllowedChatSenderMatcher,
type ChatSenderAllowParams,

View File

@@ -494,10 +494,6 @@
"types": "./dist/plugin-sdk/imessage.d.ts",
"default": "./dist/plugin-sdk/imessage.js"
},
"./plugin-sdk/imessage-core": {
"types": "./dist/plugin-sdk/imessage-core.d.ts",
"default": "./dist/plugin-sdk/imessage-core.js"
},
"./plugin-sdk/imessage-policy": {
"types": "./dist/plugin-sdk/imessage-policy.d.ts",
"default": "./dist/plugin-sdk/imessage-policy.js"
@@ -506,10 +502,6 @@
"types": "./dist/plugin-sdk/imessage-runtime.d.ts",
"default": "./dist/plugin-sdk/imessage-runtime.js"
},
"./plugin-sdk/imessage-targets": {
"types": "./dist/plugin-sdk/imessage-targets.d.ts",
"default": "./dist/plugin-sdk/imessage-targets.js"
},
"./plugin-sdk/irc": {
"types": "./dist/plugin-sdk/irc.d.ts",
"default": "./dist/plugin-sdk/irc.js"

View File

@@ -113,10 +113,8 @@
"image-generation",
"image-generation-core",
"imessage",
"imessage-core",
"imessage-policy",
"imessage-runtime",
"imessage-targets",
"irc",
"irc-surface",
"kimi-coding",

View File

@@ -427,19 +427,6 @@ export const GENERATED_PLUGIN_SDK_FACADES = [
"isHuggingfacePolicyLocked",
],
},
{
subpath: "imessage-targets",
source: pluginSource("imessage", "api.js"),
exports: [
"normalizeIMessageHandle",
"parseChatAllowTargetPrefixes",
"parseChatTargetPrefixesOrThrow",
"resolveServicePrefixedAllowTarget",
"resolveServicePrefixedTarget",
"ParsedChatTarget",
],
typeExports: ["ParsedChatTarget"],
},
{
subpath: "image-generation-runtime",
source: pluginSource("image-generation-core", "runtime-api.js"),

View File

@@ -64,7 +64,10 @@ function toChatChannelMeta(params: {
function buildChatChannelMetaById(): Record<ChatChannelId, ChatChannelMeta> {
const entries = new Map<ChatChannelId, ChatChannelMeta>();
for (const entry of listBundledPluginMetadata()) {
for (const entry of listBundledPluginMetadata({
includeChannelConfigs: true,
includeSyntheticChannelConfigs: false,
})) {
const channel =
entry.packageManifest && "channel" in entry.packageManifest
? entry.packageManifest.channel

View File

@@ -10,7 +10,7 @@ export async function ensureConfiguredBindingTargetReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;
}): Promise<{ ok: true } | { ok: false; error: string }> {
ensureStatefulTargetBuiltinsRegistered();
await ensureStatefulTargetBuiltinsRegistered();
if (!params.bindingResolution) {
return { ok: true };
}
@@ -32,7 +32,7 @@ export async function resetConfiguredBindingTargetInPlace(params: {
sessionKey: string;
reason: "new" | "reset";
}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> {
ensureStatefulTargetBuiltinsRegistered();
await ensureStatefulTargetBuiltinsRegistered();
const resolved = resolveStatefulBindingTargetBySessionKey({
cfg: params.cfg,
sessionKey: params.sessionKey,
@@ -53,7 +53,7 @@ export async function ensureConfiguredBindingTargetSession(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> {
ensureStatefulTargetBuiltinsRegistered();
await ensureStatefulTargetBuiltinsRegistered();
const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId);
if (!driver) {
return {

View File

@@ -0,0 +1,269 @@
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };
export type ChatTargetPrefixesParams = {
trimmed: string;
lower: string;
chatIdPrefixes: string[];
chatGuidPrefixes: string[];
chatIdentifierPrefixes: string[];
};
export type ParsedChatTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string };
export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
export type ChatSenderAllowParams = {
allowFrom: Array<string | number>;
sender: string;
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
};
function isAllowedParsedChatSender<TParsed extends ParsedChatAllowTarget>(params: {
allowFrom: Array<string | number>;
sender: string;
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
normalizeSender: (sender: string) => string;
parseAllowTarget: (entry: string) => TParsed;
}): boolean {
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
if (allowFrom.length === 0) {
return false;
}
if (allowFrom.includes("*")) {
return true;
}
const senderNormalized = params.normalizeSender(params.sender);
const chatId = params.chatId ?? undefined;
const chatGuid = params.chatGuid?.trim();
const chatIdentifier = params.chatIdentifier?.trim();
for (const entry of allowFrom) {
if (!entry) {
continue;
}
const parsed = params.parseAllowTarget(entry);
if (parsed.kind === "chat_id" && chatId !== undefined) {
if (parsed.chatId === chatId) {
return true;
}
} else if (parsed.kind === "chat_guid" && chatGuid) {
if (parsed.chatGuid === chatGuid) {
return true;
}
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
if (parsed.chatIdentifier === chatIdentifier) {
return true;
}
} else if (parsed.kind === "handle" && senderNormalized) {
if (parsed.handle === senderNormalized) {
return true;
}
}
}
return false;
}
function stripPrefix(value: string, prefix: string): string {
return value.slice(prefix.length).trim();
}
function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean {
return prefixes.some((prefix) => value.startsWith(prefix));
}
export function resolveServicePrefixedTarget<TService extends string, TTarget>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<ServicePrefix<TService>>;
isChatTarget: (remainderLower: string) => boolean;
parseTarget: (remainder: string) => TTarget;
}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null {
for (const { prefix, service } of params.servicePrefixes) {
if (!params.lower.startsWith(prefix)) {
continue;
}
const remainder = stripPrefix(params.trimmed, prefix);
if (!remainder) {
throw new Error(`${prefix} target is required`);
}
const remainderLower = remainder.toLowerCase();
if (params.isChatTarget(remainderLower)) {
return params.parseTarget(remainder);
}
return { kind: "handle", to: remainder, service };
}
return null;
}
export function resolveServicePrefixedChatTarget<TService extends string, TTarget>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<ServicePrefix<TService>>;
chatIdPrefixes: string[];
chatGuidPrefixes: string[];
chatIdentifierPrefixes: string[];
extraChatPrefixes?: string[];
parseTarget: (remainder: string) => TTarget;
}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null {
const chatPrefixes = [
...params.chatIdPrefixes,
...params.chatGuidPrefixes,
...params.chatIdentifierPrefixes,
...(params.extraChatPrefixes ?? []),
];
return resolveServicePrefixedTarget({
trimmed: params.trimmed,
lower: params.lower,
servicePrefixes: params.servicePrefixes,
isChatTarget: (remainderLower) => startsWithAnyPrefix(remainderLower, chatPrefixes),
parseTarget: params.parseTarget,
});
}
export function parseChatTargetPrefixesOrThrow(
params: ChatTargetPrefixesParams,
): ParsedChatTarget | null {
for (const prefix of params.chatIdPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
const chatId = Number.parseInt(value, 10);
if (!Number.isFinite(chatId)) {
throw new Error(`Invalid chat_id: ${value}`);
}
return { kind: "chat_id", chatId };
}
}
for (const prefix of params.chatGuidPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
if (!value) {
throw new Error("chat_guid is required");
}
return { kind: "chat_guid", chatGuid: value };
}
}
for (const prefix of params.chatIdentifierPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
if (!value) {
throw new Error("chat_identifier is required");
}
return { kind: "chat_identifier", chatIdentifier: value };
}
}
return null;
}
export function resolveServicePrefixedAllowTarget<TAllowTarget>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<{ prefix: string }>;
parseAllowTarget: (remainder: string) => TAllowTarget;
}): (TAllowTarget | { kind: "handle"; handle: string }) | null {
for (const { prefix } of params.servicePrefixes) {
if (!params.lower.startsWith(prefix)) {
continue;
}
const remainder = stripPrefix(params.trimmed, prefix);
if (!remainder) {
return { kind: "handle", handle: "" };
}
return params.parseAllowTarget(remainder);
}
return null;
}
export function resolveServicePrefixedOrChatAllowTarget<
TAllowTarget extends ParsedChatAllowTarget,
>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<{ prefix: string }>;
parseAllowTarget: (remainder: string) => TAllowTarget;
chatIdPrefixes: string[];
chatGuidPrefixes: string[];
chatIdentifierPrefixes: string[];
}): TAllowTarget | null {
const servicePrefixed = resolveServicePrefixedAllowTarget({
trimmed: params.trimmed,
lower: params.lower,
servicePrefixes: params.servicePrefixes,
parseAllowTarget: params.parseAllowTarget,
});
if (servicePrefixed) {
return servicePrefixed as TAllowTarget;
}
const chatTarget = parseChatAllowTargetPrefixes({
trimmed: params.trimmed,
lower: params.lower,
chatIdPrefixes: params.chatIdPrefixes,
chatGuidPrefixes: params.chatGuidPrefixes,
chatIdentifierPrefixes: params.chatIdentifierPrefixes,
});
if (chatTarget) {
return chatTarget as TAllowTarget;
}
return null;
}
export function createAllowedChatSenderMatcher<TParsed extends ParsedChatAllowTarget>(params: {
normalizeSender: (sender: string) => string;
parseAllowTarget: (entry: string) => TParsed;
}): (input: ChatSenderAllowParams) => boolean {
return (input) =>
isAllowedParsedChatSender({
allowFrom: input.allowFrom,
sender: input.sender,
chatId: input.chatId,
chatGuid: input.chatGuid,
chatIdentifier: input.chatIdentifier,
normalizeSender: params.normalizeSender,
parseAllowTarget: params.parseAllowTarget,
});
}
export function parseChatAllowTargetPrefixes(
params: ChatTargetPrefixesParams,
): ParsedChatTarget | null {
for (const prefix of params.chatIdPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
}
}
for (const prefix of params.chatGuidPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
if (value) {
return { kind: "chat_guid", chatGuid: value };
}
}
}
for (const prefix of params.chatIdentifierPrefixes) {
if (params.lower.startsWith(prefix)) {
const value = stripPrefix(params.trimmed, prefix);
if (value) {
return { kind: "chat_identifier", chatIdentifier: value };
}
}
}
return null;
}

View File

@@ -1,4 +1,4 @@
import { normalizeIMessageHandle } from "../../../plugin-sdk/imessage-targets.js";
import { normalizeIMessageHandle } from "../../../plugin-sdk/imessage-policy.js";
import { looksLikeHandleOrPhoneTarget, trimMessagingTarget } from "./shared.js";
// Service prefixes that indicate explicit delivery method; must be preserved during normalization

View File

@@ -1,13 +1,29 @@
import { acpStatefulBindingTargetDriver } from "./acp-stateful-target-driver.js";
import {
registerStatefulBindingTargetDriver,
unregisterStatefulBindingTargetDriver,
} from "./stateful-target-drivers.js";
export function ensureStatefulTargetBuiltinsRegistered(): void {
registerStatefulBindingTargetDriver(acpStatefulBindingTargetDriver);
let builtinsRegisteredPromise: Promise<void> | null = null;
export async function ensureStatefulTargetBuiltinsRegistered(): Promise<void> {
if (builtinsRegisteredPromise) {
await builtinsRegisteredPromise;
return;
}
builtinsRegisteredPromise = (async () => {
const { acpStatefulBindingTargetDriver } = await import("./acp-stateful-target-driver.js");
registerStatefulBindingTargetDriver(acpStatefulBindingTargetDriver);
})();
try {
await builtinsRegisteredPromise;
} catch (error) {
builtinsRegisteredPromise = null;
throw error;
}
}
export function resetStatefulTargetBuiltinsForTesting(): void {
export async function resetStatefulTargetBuiltinsForTesting(): Promise<void> {
builtinsRegisteredPromise = null;
const { acpStatefulBindingTargetDriver } = await import("./acp-stateful-target-driver.js");
unregisterStatefulBindingTargetDriver(acpStatefulBindingTargetDriver.id);
}

View File

@@ -1,6 +1,14 @@
import type { MsgContext } from "../auto-reply/templating.js";
import type { OpenClawConfig } from "../config/config.js";
import { recordSessionMetaFromInbound, resolveStorePath } from "../config/sessions.js";
let inboundSessionRuntimePromise: Promise<
typeof import("../config/sessions/inbound.runtime.js")
> | null = null;
function loadInboundSessionRuntime() {
inboundSessionRuntimePromise ??= import("../config/sessions/inbound.runtime.js");
return inboundSessionRuntimePromise;
}
export async function recordInboundSessionMetaSafe(params: {
cfg: OpenClawConfig;
@@ -9,11 +17,12 @@ export async function recordInboundSessionMetaSafe(params: {
ctx: MsgContext;
onError?: (error: unknown) => void;
}): Promise<void> {
const storePath = resolveStorePath(params.cfg.session?.store, {
const runtime = await loadInboundSessionRuntime();
const storePath = runtime.resolveStorePath(params.cfg.session?.store, {
agentId: params.agentId,
});
try {
await recordSessionMetaFromInbound({
await runtime.recordSessionMetaFromInbound({
storePath,
sessionKey: params.sessionKey,
ctx: params.ctx,

View File

@@ -4,7 +4,7 @@ import type { MsgContext } from "../auto-reply/templating.js";
const recordSessionMetaFromInboundMock = vi.fn((_args?: unknown) => Promise.resolve(undefined));
const updateLastRouteMock = vi.fn((_args?: unknown) => Promise.resolve(undefined));
vi.mock("../config/sessions.js", () => ({
vi.mock("../config/sessions/inbound.runtime.js", () => ({
recordSessionMetaFromInbound: (args: unknown) => recordSessionMetaFromInboundMock(args),
updateLastRoute: (args: unknown) => updateLastRouteMock(args),
}));

View File

@@ -1,10 +1,14 @@
import type { MsgContext } from "../auto-reply/templating.js";
import {
recordSessionMetaFromInbound,
type GroupKeyResolution,
type SessionEntry,
updateLastRoute,
} from "../config/sessions.js";
import type { GroupKeyResolution, SessionEntry } from "../config/sessions/types.js";
let inboundSessionRuntimePromise: Promise<
typeof import("../config/sessions/inbound.runtime.js")
> | null = null;
function loadInboundSessionRuntime() {
inboundSessionRuntimePromise ??= import("../config/sessions/inbound.runtime.js");
return inboundSessionRuntimePromise;
}
function normalizeSessionStoreKey(sessionKey: string): string {
return sessionKey.trim().toLowerCase();
@@ -49,13 +53,16 @@ export async function recordInboundSession(params: {
}): Promise<void> {
const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params;
const canonicalSessionKey = normalizeSessionStoreKey(sessionKey);
void recordSessionMetaFromInbound({
storePath,
sessionKey: canonicalSessionKey,
ctx,
groupResolution,
createIfMissing,
}).catch(params.onRecordError);
const runtime = await loadInboundSessionRuntime();
void runtime
.recordSessionMetaFromInbound({
storePath,
sessionKey: canonicalSessionKey,
ctx,
groupResolution,
createIfMissing,
})
.catch(params.onRecordError);
const update = params.updateLastRoute;
if (!update) {
@@ -65,7 +72,7 @@ export async function recordInboundSession(params: {
return;
}
const targetSessionKey = normalizeSessionStoreKey(update.sessionKey);
await updateLastRoute({
await runtime.updateLastRoute({
storePath,
sessionKey: targetSessionKey,
deliveryContext: {

View File

@@ -0,0 +1,2 @@
export { resolveStorePath } from "./paths.js";
export { recordSessionMetaFromInbound, updateLastRoute } from "./store.js";

View File

@@ -223,17 +223,6 @@ export interface PluginSdkFacadeTypeMap {
};
types: {};
};
"imessage-targets": {
module: typeof import("@openclaw/imessage/api.js");
sourceModules: {
source1: {
module: typeof import("@openclaw/imessage/api.js");
};
};
types: {
ParsedChatTarget: import("@openclaw/imessage/api.js").ParsedChatTarget;
};
};
"image-generation-runtime": {
module: typeof import("@openclaw/image-generation-core/runtime-api.js");
sourceModules: {

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { normalizeConversationText } from "../../acp/conversation-id.js";
import { listBundledChannelPlugins } from "../../channels/plugins/bundled.js";
import { normalizeAnyChannelId } from "../../channels/registry.js";
import { resolveStateDir } from "../../config/paths.js";
import { loadJsonFile } from "../../infra/json-file.js";
@@ -128,9 +127,11 @@ function resolveChannelSupportsCurrentConversationBinding(channel: string): bool
const matchesPluginId = (plugin: { id: string; meta?: { aliases?: readonly string[] } }) =>
plugin.id === normalized ||
(plugin.meta?.aliases ?? []).some((alias) => alias.trim().toLowerCase() === normalized);
const plugin =
getActivePluginChannelRegistry()?.channels.find((entry) => matchesPluginId(entry.plugin))
?.plugin ?? listBundledChannelPlugins().find((entry) => matchesPluginId(entry));
// Keep this resolver on the active runtime registry only. Importing bundled
// channel loaders here creates a module cycle through plugin-sdk surfaces.
const plugin = getActivePluginChannelRegistry()?.channels.find((entry) =>
matchesPluginId(entry.plugin),
)?.plugin;
return plugin?.conversationBindings?.supportsCurrentConversationBinding === true;
}

View File

@@ -1,10 +1,10 @@
import type { OpenClawConfig } from "../config/config.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
import {
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedTarget,
type ParsedChatTarget,
} from "./imessage-targets.js";
} from "./channel-targets.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
// Narrow plugin-sdk surface for the bundled BlueBubbles plugin.
// Keep this list additive and scoped to the conversation-binding seam only.
@@ -324,7 +324,7 @@ export {
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
type ParsedChatTarget,
} from "./imessage-targets.js";
} from "./channel-targets.js";
export { stripMarkdown } from "./text-runtime.js";
export { parseFiniteNumber } from "../infra/parse-finite-number.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";

View File

@@ -13,11 +13,10 @@ const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const REPO_ROOT = resolve(ROOT_DIR, "..");
const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set(GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES);
ALLOWED_EXTENSION_PUBLIC_SURFACES.add("test-api.js");
const BUNDLED_EXTENSION_IDS = new Set(
readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== "shared")
.map((entry) => entry.name),
);
const BUNDLED_EXTENSION_IDS = readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== "shared")
.map((entry) => entry.name)
.toSorted((left, right) => right.length - left.length);
const GUARDED_CHANNEL_EXTENSIONS = new Set([
"bluebubbles",
"discord",
@@ -190,6 +189,7 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [
"diffs",
"feishu",
"google",
"imessage",
"irc",
"llm-task",
"line",
@@ -472,7 +472,12 @@ function expectNoCrossPluginSdkFacadeImports(file: string, imports: string[]): v
continue;
}
const targetSubpath = specifier.slice("openclaw/plugin-sdk/".length);
if (!BUNDLED_EXTENSION_IDS.has(targetSubpath) || targetSubpath === currentExtensionId) {
const targetExtensionId =
BUNDLED_EXTENSION_IDS.find(
(extensionId) =>
targetSubpath === extensionId || targetSubpath.startsWith(`${extensionId}-`),
) ?? null;
if (!targetExtensionId || targetExtensionId === currentExtensionId) {
continue;
}
expect.fail(
@@ -585,7 +590,7 @@ describe("channel import guardrails", () => {
expect(
text,
`${normalized} should import ${extensionId} helpers via the local api barrel`,
).not.toMatch(new RegExp(`["']openclaw/plugin-sdk/${extensionId}["']`, "u"));
).not.toMatch(new RegExp(`["']openclaw/plugin-sdk/${extensionId}(?:["'/])`, "u"));
}
}
});

View File

@@ -23,6 +23,20 @@ export {
type MessagingTargetKind,
type MessagingTargetParseOptions,
} from "../channels/targets.js";
export {
createAllowedChatSenderMatcher,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedChatTarget,
resolveServicePrefixedOrChatAllowTarget,
resolveServicePrefixedTarget,
type ChatSenderAllowParams,
type ChatTargetPrefixesParams,
type ParsedChatAllowTarget,
type ParsedChatTarget,
type ServicePrefix,
} from "../channels/plugins/chat-target-prefixes.js";
export {
buildUnresolvedTargetResults,
resolveTargetsWithOptionalToken,

View File

@@ -1,134 +0,0 @@
import {
normalizeIMessageHandle,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
type ParsedChatTarget,
} from "./imessage-targets.js";
export type { ChannelPlugin } from "./channel-plugin-common.js";
export {
DEFAULT_ACCOUNT_ID,
buildChannelConfigSchema,
deleteAccountFromConfigSection,
getChatChannelMeta,
setAccountEnabledInConfigSection,
} from "./channel-plugin-common.js";
export {
formatTrimmedAllowFromEntries,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
} from "./channel-config-helpers.js";
export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js";
export {
normalizeIMessageHandle,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
type ParsedChatTarget,
} from "./imessage-targets.js";
type IMessageService = "imessage" | "sms" | "auto";
type IMessageTarget = ParsedChatTarget | { kind: "handle"; to: string; service: IMessageService };
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
const SERVICE_PREFIXES: Array<{ prefix: string; service: IMessageService }> = [
{ prefix: "imessage:", service: "imessage" },
{ prefix: "sms:", service: "sms" },
{ prefix: "auto:", service: "auto" },
];
function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean {
return prefixes.some((prefix) => value.startsWith(prefix));
}
function parseIMessageTarget(raw: string): IMessageTarget {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("iMessage target is required");
}
const lower = trimmed.toLowerCase();
const servicePrefixed = resolveServicePrefixedTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
isChatTarget: (remainderLower) =>
startsWithAnyPrefix(remainderLower, [
...CHAT_ID_PREFIXES,
...CHAT_GUID_PREFIXES,
...CHAT_IDENTIFIER_PREFIXES,
]),
parseTarget: parseIMessageTarget,
});
if (servicePrefixed) {
return servicePrefixed;
}
const chatTarget = parseChatTargetPrefixesOrThrow({
trimmed,
lower,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (chatTarget) {
return chatTarget;
}
return { kind: "handle", to: trimmed, service: "auto" };
}
export function normalizeIMessageAcpConversationId(
conversationId: string,
): { conversationId: string } | null {
const trimmed = conversationId.trim();
if (!trimmed) {
return null;
}
try {
const parsed = parseIMessageTarget(trimmed);
if (parsed.kind === "handle") {
const handle = normalizeIMessageHandle(parsed.to);
return handle ? { conversationId: handle } : null;
}
if (parsed.kind === "chat_id") {
return { conversationId: String(parsed.chatId) };
}
if (parsed.kind === "chat_guid") {
return { conversationId: parsed.chatGuid };
}
return { conversationId: parsed.chatIdentifier };
} catch {
const handle = normalizeIMessageHandle(trimmed);
return handle ? { conversationId: handle } : null;
}
}
export function matchIMessageAcpConversation(params: {
bindingConversationId: string;
conversationId: string;
}): { conversationId: string; matchPriority: number } | null {
const binding = normalizeIMessageAcpConversationId(params.bindingConversationId);
const conversation = normalizeIMessageAcpConversationId(params.conversationId);
if (!binding || !conversation) {
return null;
}
if (binding.conversationId !== conversation.conversationId) {
return null;
}
return {
conversationId: conversation.conversationId,
matchPriority: 2,
};
}
export function resolveIMessageConversationIdFromTarget(target: string): string | undefined {
return normalizeIMessageAcpConversationId(target)?.conversationId;
}

View File

@@ -1,40 +0,0 @@
// Generated by scripts/generate-plugin-sdk-facades.mjs. Do not edit manually.
import type { PluginSdkFacadeTypeMap } from "../generated/plugin-sdk-facade-type-map.generated.js";
type FacadeEntry = PluginSdkFacadeTypeMap["imessage-targets"];
type FacadeModule = FacadeEntry["module"];
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
function loadFacadeModule(): FacadeModule {
return loadBundledPluginPublicSurfaceModuleSync<FacadeModule>({
dirName: "imessage",
artifactBasename: "api.js",
});
}
export const normalizeIMessageHandle: FacadeModule["normalizeIMessageHandle"] = ((...args) =>
loadFacadeModule()["normalizeIMessageHandle"](
...args,
)) as FacadeModule["normalizeIMessageHandle"];
export const parseChatAllowTargetPrefixes: FacadeModule["parseChatAllowTargetPrefixes"] = ((
...args
) =>
loadFacadeModule()["parseChatAllowTargetPrefixes"](
...args,
)) as FacadeModule["parseChatAllowTargetPrefixes"];
export const parseChatTargetPrefixesOrThrow: FacadeModule["parseChatTargetPrefixesOrThrow"] = ((
...args
) =>
loadFacadeModule()["parseChatTargetPrefixesOrThrow"](
...args,
)) as FacadeModule["parseChatTargetPrefixesOrThrow"];
export const resolveServicePrefixedAllowTarget: FacadeModule["resolveServicePrefixedAllowTarget"] =
((...args) =>
loadFacadeModule()["resolveServicePrefixedAllowTarget"](
...args,
)) as FacadeModule["resolveServicePrefixedAllowTarget"];
export const resolveServicePrefixedTarget: FacadeModule["resolveServicePrefixedTarget"] = ((
...args
) =>
loadFacadeModule()["resolveServicePrefixedTarget"](
...args,
)) as FacadeModule["resolveServicePrefixedTarget"];
export type ParsedChatTarget = FacadeEntry["types"]["ParsedChatTarget"];

View File

@@ -35,12 +35,16 @@ export {
normalizeIMessageMessagingTarget,
} from "../channels/plugins/normalize/imessage.js";
export {
createAllowedChatSenderMatcher,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedChatTarget,
resolveServicePrefixedOrChatAllowTarget,
resolveServicePrefixedTarget,
type ChatSenderAllowParams,
type ParsedChatTarget,
} from "./imessage-targets.js";
} from "./channel-targets.js";
export {
resolveAllowlistProviderRuntimeGroupPolicy,
@@ -70,6 +74,12 @@ type IMessageFacadeModule = {
accountId?: string;
cfg: OpenClawConfig;
}) => IMessageConversationBindingManager;
matchIMessageAcpConversation: (params: {
bindingConversationId: string;
conversationId: string;
}) => { conversationId: string; matchPriority: number } | null;
normalizeIMessageAcpConversationId: (conversationId: string) => { conversationId: string } | null;
resolveIMessageConversationIdFromTarget: (target: string) => string | undefined;
};
function loadIMessageFacadeModule(): IMessageFacadeModule {
@@ -85,3 +95,20 @@ export function createIMessageConversationBindingManager(params: {
}): IMessageConversationBindingManager {
return loadIMessageFacadeModule().createIMessageConversationBindingManager(params);
}
export function normalizeIMessageAcpConversationId(
conversationId: string,
): { conversationId: string } | null {
return loadIMessageFacadeModule().normalizeIMessageAcpConversationId(conversationId);
}
export function matchIMessageAcpConversation(params: {
bindingConversationId: string;
conversationId: string;
}): { conversationId: string; matchPriority: number } | null {
return loadIMessageFacadeModule().matchIMessageAcpConversation(params);
}
export function resolveIMessageConversationIdFromTarget(target: string): string | undefined {
return loadIMessageFacadeModule().resolveIMessageConversationIdFromTarget(target);
}

View File

@@ -199,22 +199,16 @@ describe("plugin-sdk subpath exports", () => {
expectSourceContains("telegram", 'export * from "./telegram-core.js";');
expectSourceContains("telegram", 'export * from "./telegram-runtime.js";');
expectSourceMentions("imessage", [
"normalizeIMessageAcpConversationId",
"matchIMessageAcpConversation",
"normalizeIMessageHandle",
"parseChatAllowTargetPrefixes",
"parseChatTargetPrefixesOrThrow",
"resolveIMessageConversationIdFromTarget",
"resolveServicePrefixedAllowTarget",
"resolveServicePrefixedTarget",
"chunkTextForOutbound",
]);
expectSourceMentions("imessage-core", [
"normalizeIMessageAcpConversationId",
"matchIMessageAcpConversation",
"resolveIMessageConversationIdFromTarget",
"parseChatAllowTargetPrefixes",
"parseChatTargetPrefixesOrThrow",
"resolveServicePrefixedAllowTarget",
"resolveServicePrefixedTarget",
]);
expectSourceMentions("bluebubbles", [
"normalizeBlueBubblesAcpConversationId",
"matchBlueBubblesAcpConversation",
@@ -503,11 +497,18 @@ describe("plugin-sdk subpath exports", () => {
"applyChannelMatchMeta",
"buildChannelKeyCandidates",
"buildMessagingTarget",
"createAllowedChatSenderMatcher",
"ensureTargetId",
"parseChatAllowTargetPrefixes",
"parseMentionPrefixOrAtUserTarget",
"parseChatTargetPrefixesOrThrow",
"requireTargetKind",
"resolveChannelEntryMatchWithFallback",
"resolveChannelMatchConfig",
"resolveServicePrefixedAllowTarget",
"resolveServicePrefixedChatTarget",
"resolveServicePrefixedOrChatAllowTarget",
"resolveServicePrefixedTarget",
"resolveTargetsWithOptionalToken",
]);
expectSourceMentions("channel-config-writes", [

View File

@@ -360,6 +360,7 @@ function collectBundledChannelConfigs(params: {
function collectBundledPluginMetadataForPackageRoot(
packageRoot: string,
includeChannelConfigs: boolean,
includeSyntheticChannelConfigs: boolean,
): readonly BundledPluginMetadata[] {
const scanDir = resolveBundledPluginScanDir(packageRoot);
if (!scanDir || !fs.existsSync(scanDir)) {
@@ -404,13 +405,14 @@ function collectBundledPluginMetadataForPackageRoot(
...(setupSourcePath ? { setupEntry: setupSourcePath } : {}),
});
const runtimeSidecarArtifacts = collectRuntimeSidecarArtifacts(publicSurfaceArtifacts);
const channelConfigs = includeChannelConfigs
? collectBundledChannelConfigs({
pluginDir,
manifest: manifestResult.manifest,
packageManifest,
})
: manifestResult.manifest.channelConfigs;
const channelConfigs =
includeChannelConfigs && includeSyntheticChannelConfigs
? collectBundledChannelConfigs({
pluginDir,
manifest: manifestResult.manifest,
packageManifest,
})
: manifestResult.manifest.channelConfigs;
entries.push({
dirName,
@@ -448,16 +450,27 @@ function collectBundledPluginMetadataForPackageRoot(
export function listBundledPluginMetadata(params?: {
rootDir?: string;
includeChannelConfigs?: boolean;
includeSyntheticChannelConfigs?: boolean;
}): readonly BundledPluginMetadata[] {
const rootDir = path.resolve(params?.rootDir ?? OPENCLAW_PACKAGE_ROOT);
const includeChannelConfigs = params?.includeChannelConfigs ?? !RUNNING_FROM_BUILT_ARTIFACT;
const cacheKey = JSON.stringify({ rootDir, includeChannelConfigs });
const includeSyntheticChannelConfigs =
params?.includeSyntheticChannelConfigs ?? includeChannelConfigs;
const cacheKey = JSON.stringify({
rootDir,
includeChannelConfigs,
includeSyntheticChannelConfigs,
});
const cached = bundledPluginMetadataCache.get(cacheKey);
if (cached) {
return cached;
}
const entries = Object.freeze(
collectBundledPluginMetadataForPackageRoot(rootDir, includeChannelConfigs),
collectBundledPluginMetadataForPackageRoot(
rootDir,
includeChannelConfigs,
includeSyntheticChannelConfigs,
),
);
bundledPluginMetadataCache.set(cacheKey, entries);
return entries;

View File

@@ -1,6 +1,6 @@
import { imessageOutbound } from "../../test/channel-outbounds.js";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../channels/plugins/types.js";
import { normalizeIMessageHandle } from "../plugin-sdk/imessage-targets.js";
import { normalizeIMessageHandle } from "../plugin-sdk/imessage-policy.js";
import { collectStatusIssuesFromLastError } from "../plugin-sdk/status-helpers.js";
export const createIMessageTestPlugin = (params?: {