Files
openclaw/extensions/qqbot/src/doctor-contract.ts
T
Peter Steinberger da4a656cdb improve: doctor migration checks no longer load every bundled plugin runtime (#120678)
* perf(plugins): declare doctor contract surfaces

* perf(doctor): slim migration import closures

* perf(plugins): narrow doctor declaration record surface and wire owner-test lane

Registry records carry only the doctorContract declaration instead of the whole
parsed manifest, and check:changed now selects the src/plugins-owned declaration
honesty and closure-guard tests for extension module/manifest changes so
cross-lane drift cannot pass PR classification.

* fix(doctor): keep control-plane dist imports require-safe

Keep doctor and channel control-plane chunks off exec-class dependencies, and enforce native require(esm) loading during postbuild.

* chore(plugin-sdk): regenerate API baseline

* chore(plugin-sdk): sync export ordering

* fix(plugins): satisfy doctor contract CI boundaries

* perf(doctor): make qqbot doctor closure dependency-light

qqbot was the last plugin above 5s in doctor state-migration enumeration
(~8s under tsx/jiti). The cost was not the state-key builder (already a
leaf): its doctor closure value-imported the runtime-doctor SDK barrel,
whose plugin-state-store/state-db re-exports pull kysely (~330 modules),
plus security-runtime for one fileExists (~200 modules), all resolved
per-module by jiti during enumeration.

Split the migration-define helpers and light re-exports into a new
private-local plugin-sdk/runtime-doctor-migrations subpath; runtime-doctor
re-exports it so its public surface is byte-identical (API baseline hash
unchanged). qqbot's doctor-contract and state-migrations now import only
the light subpath, swapping fileExists for the equivalent async
legacyStateFileExists already in the closure.

qqbot enumeration: ~8.0s/531 modules -> ~0.25s/18 modules.

* chore(plugin-sdk): drop private-local subpath from API baseline

runtime-doctor-migrations is private-local-only; the baseline tracks public
modules, and the earlier line was generated before the classification.

* fix(plugins): register runtime-doctor-migrations boundary paths

The private-local subpath list feeds the extension package boundary map;
the shared paths config and xai's derived overrides must carry the same
entry or the boundary contract test fails.
2026-08-08 13:29:18 -07:00

178 lines
6.4 KiB
TypeScript

// Qqbot plugin module implements doctor contract behavior.
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/channel-policy";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
asObjectRecord,
defineKeyMoveMigration,
hasLegacyAccountStreamingAliases,
normalizeChannelConfigEntries,
} from "openclaw/plugin-sdk/runtime-doctor-migrations";
const RESTRICTED_GROUP_TOOLS: GroupToolPolicyConfig = {
deny: ["exec", "read", "write"],
};
const streamingTransportMigration = defineKeyMoveMigration({
from: ["streaming", "c2cStreamApi"],
to: ["streaming", "nativeTransport"],
match: (value) => value !== undefined,
sourceOwn: false,
});
// QQBot's legacy scalar `streaming` is not a plain mode alias: `true` enabled
// block streaming AND the official C2C stream API (shouldUseOfficialC2cStream
// treated `true` like `c2cStreamApi: true`), while `false` only disabled block
// streaming. It migrates to the nested `{mode, nativeTransport}` shape here
// instead of the shared alias DSL because qqbot has no flat delivery aliases
// and its strict streaming schema rejects the DSL's chunkMode/block slots.
// No account seeding: named accounts never inherit root config (bridge/config
// resolves them standalone), and the boolean carries its full semantics.
function hasLegacyStreamingValue(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return typeof entry.streaming === "boolean" || streamingTransportMigration.hasLegacy(entry);
}
function migrateStreamingValue(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
const streaming = params.entry.streaming;
const path = `${params.pathPrefix}.streaming`;
if (typeof streaming === "boolean") {
const next: Record<string, unknown> = streaming
? { mode: "partial", nativeTransport: true }
: { mode: "off" };
params.changes.push(`Moved ${path} (boolean) → ${path}.mode (${next.mode as string}).`);
if (streaming) {
// `streaming: true` also enabled the official C2C stream API.
params.changes.push(`Moved ${path} (boolean) → ${path}.nativeTransport.`);
}
return { entry: { ...params.entry, streaming: next }, changed: true };
}
return streamingTransportMigration.normalize(params);
}
function migrateToolPolicy(value: unknown): GroupToolPolicyConfig | undefined {
if (value === "none") {
return { deny: ["*"] };
}
if (value === "full") {
return { allow: [] };
}
if (value === "restricted") {
return { ...RESTRICTED_GROUP_TOOLS };
}
return undefined;
}
function describeToolPolicy(value: unknown): string {
return typeof value === "string" ? value : String(value);
}
const groupToolPolicyMigration = defineKeyMoveMigration({
scope: ["*"],
from: ["toolPolicy"],
to: ["tools"],
match: (value) => value !== undefined,
sourceOwn: false,
map: (value) => {
const policy = migrateToolPolicy(value);
return policy ? { value: policy } : null;
},
movedMessage: ({ sourcePath, targetPath, sourceValue }) =>
`Moved ${sourcePath}=${describeToolPolicy(sourceValue)} to ${targetPath}.`,
existingMessage: ({ sourcePath, targetPath }) =>
`Removed ${sourcePath} (${targetPath} already exists).`,
invalidMessage: ({ sourcePath, sourceValue }) =>
`Removed unsupported ${sourcePath}=${describeToolPolicy(sourceValue)}.`,
});
const voiceDirectUploadFormatsMigration = defineKeyMoveMigration({
from: ["voiceDirectUploadFormats"],
to: ["audioFormatPolicy", "uploadDirectFormats"],
match: (value) => value !== undefined,
sourceOwn: false,
});
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "qqbot"],
message:
'channels.qqbot streaming aliases and voiceDirectUploadFormats are legacy; use streaming.{mode,nativeTransport} and audioFormatPolicy.uploadDirectFormats. Run "openclaw doctor --fix".',
match: (value) =>
hasLegacyStreamingValue(value) || voiceDirectUploadFormatsMigration.hasLegacy(value),
},
{
path: ["channels", "qqbot", "accounts"],
message:
'channels.qqbot account streaming aliases and voiceDirectUploadFormats are legacy; use streaming.{mode,nativeTransport} and audioFormatPolicy.uploadDirectFormats. Run "openclaw doctor --fix".',
match: (value) =>
hasLegacyAccountStreamingAliases(
value,
(entry) =>
hasLegacyStreamingValue(entry) || voiceDirectUploadFormatsMigration.hasLegacy(entry),
),
},
{
path: ["channels", "qqbot", "groups"],
message:
'channels.qqbot.groups.<id>.toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.groups.<id>.tools instead. Run "openclaw doctor --fix".',
match: groupToolPolicyMigration.hasLegacy,
},
{
path: ["channels", "qqbot", "accounts"],
message:
'channels.qqbot.accounts.<id>.groups.<groupId>.toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.accounts.<id>.groups.<groupId>.tools instead. Run "openclaw doctor --fix".',
match: (value) =>
hasLegacyAccountStreamingAliases(value, (account) =>
groupToolPolicyMigration.hasLegacy(asObjectRecord(account)?.groups),
),
},
];
function normalizeQqbotEntry(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let { entry, changed } = migrateStreamingValue(params);
const audioFormats = voiceDirectUploadFormatsMigration.normalize({
...params,
entry,
});
entry = audioFormats.entry;
changed ||= audioFormats.changed;
const groups = asObjectRecord(entry.groups);
if (!groups) {
return { entry, changed };
}
const migrated = groupToolPolicyMigration.normalize({
entry: groups,
pathPrefix: `${params.pathPrefix}.groups`,
changes: params.changes,
});
return migrated.changed
? { entry: { ...entry, groups: migrated.entry }, changed: true }
: { entry, changed };
}
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
return normalizeChannelConfigEntries({
cfg,
channelId: "qqbot",
normalizeEntry: normalizeQqbotEntry,
});
}