mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 01:21:36 +00:00
fix: stabilize serial test suite
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { vi } from "vitest";
|
||||
import type { BuildTelegramMessageContextParams, TelegramMediaRef } from "./bot-message-context.js";
|
||||
|
||||
export const baseTelegramMessageContextConfig = {
|
||||
@@ -23,6 +22,7 @@ export async function buildTelegramMessageContextForTest(
|
||||
): Promise<
|
||||
Awaited<ReturnType<typeof import("./bot-message-context.js").buildTelegramMessageContext>>
|
||||
> {
|
||||
const { vi } = await import("vitest");
|
||||
const { buildTelegramMessageContext } = await import("./bot-message-context.js");
|
||||
return await buildTelegramMessageContext({
|
||||
primaryCtx: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
|
||||
const ShipSchema = z.string().min(1);
|
||||
|
||||
286
src/channels/read-only-account-inspect.telegram.ts
Normal file
286
src/channels/read-only-account-inspect.telegram.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { coerceSecretRef } from "../config/types.secrets.js";
|
||||
import type { TelegramAccountConfig } from "../config/types.telegram.js";
|
||||
import { tryReadSecretFileSync } from "../infra/secret-file.js";
|
||||
import {
|
||||
resolveAccountWithDefaultFallback,
|
||||
listCombinedAccountIds,
|
||||
resolveListedDefaultAccountId,
|
||||
resolveAccountEntry,
|
||||
} from "../plugin-sdk/account-core.js";
|
||||
import { resolveDefaultSecretProviderAlias } from "../plugin-sdk/provider-auth.js";
|
||||
import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
|
||||
export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing";
|
||||
|
||||
export type InspectedTelegramAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
token: string;
|
||||
tokenSource: "env" | "tokenFile" | "config" | "none";
|
||||
tokenStatus: TelegramCredentialStatus;
|
||||
configured: boolean;
|
||||
config: TelegramAccountConfig;
|
||||
};
|
||||
|
||||
export function normalizeTelegramAllowFromEntry(raw: unknown): string {
|
||||
const base = typeof raw === "string" ? raw : typeof raw === "number" ? String(raw) : "";
|
||||
return base
|
||||
.trim()
|
||||
.replace(/^(telegram|tg):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function isNumericTelegramUserId(raw: string): boolean {
|
||||
return /^-?\d+$/.test(raw);
|
||||
}
|
||||
|
||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
|
||||
if (key) {
|
||||
ids.add(normalizeAccountId(key));
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return listCombinedAccountIds({
|
||||
configuredAccountIds: listConfiguredAccountIds(cfg),
|
||||
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
|
||||
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
|
||||
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
|
||||
if (boundDefault) {
|
||||
return boundDefault;
|
||||
}
|
||||
return resolveListedDefaultAccountId({
|
||||
accountIds: listTelegramAccountIds(cfg),
|
||||
configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTelegramAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): TelegramAccountConfig | undefined {
|
||||
return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalizeAccountId(accountId));
|
||||
}
|
||||
|
||||
function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig {
|
||||
const {
|
||||
accounts: _ignored,
|
||||
defaultAccount: _ignoredDefaultAccount,
|
||||
groups: channelGroups,
|
||||
...base
|
||||
} = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & {
|
||||
accounts?: unknown;
|
||||
defaultAccount?: unknown;
|
||||
};
|
||||
const account = resolveTelegramAccountConfig(cfg, accountId) ?? {};
|
||||
const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {});
|
||||
const groups = account.groups ?? (configuredAccountIds.length > 1 ? undefined : channelGroups);
|
||||
return { ...base, ...account, groups };
|
||||
}
|
||||
|
||||
function inspectTokenFile(pathValue: unknown): {
|
||||
token: string;
|
||||
tokenSource: "tokenFile" | "none";
|
||||
tokenStatus: TelegramCredentialStatus;
|
||||
} | null {
|
||||
const tokenFile = typeof pathValue === "string" ? pathValue.trim() : "";
|
||||
if (!tokenFile) {
|
||||
return null;
|
||||
}
|
||||
const token = tryReadSecretFileSync(tokenFile, "Telegram bot token", {
|
||||
rejectSymlink: true,
|
||||
});
|
||||
return {
|
||||
token: token ?? "",
|
||||
tokenSource: "tokenFile",
|
||||
tokenStatus: token ? "available" : "configured_unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
function canResolveEnvSecretRefInReadOnlyPath(params: {
|
||||
cfg: OpenClawConfig;
|
||||
provider: string;
|
||||
id: string;
|
||||
}): boolean {
|
||||
const providerConfig = params.cfg.secrets?.providers?.[params.provider];
|
||||
if (!providerConfig) {
|
||||
return params.provider === resolveDefaultSecretProviderAlias(params.cfg, "env");
|
||||
}
|
||||
if (providerConfig.source !== "env") {
|
||||
return false;
|
||||
}
|
||||
const allowlist = providerConfig.allowlist;
|
||||
return !allowlist || allowlist.includes(params.id);
|
||||
}
|
||||
|
||||
function hasConfiguredSecretInput(value: unknown): boolean {
|
||||
return Boolean(coerceSecretRef(value) || (typeof value === "string" && value.trim()));
|
||||
}
|
||||
|
||||
function normalizeSecretInputString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function inspectTokenValue(params: { cfg: OpenClawConfig; value: unknown }): {
|
||||
token: string;
|
||||
tokenSource: "config" | "env" | "none";
|
||||
tokenStatus: TelegramCredentialStatus;
|
||||
} | null {
|
||||
const ref = coerceSecretRef(params.value, params.cfg.secrets?.defaults);
|
||||
if (ref?.source === "env") {
|
||||
if (
|
||||
!canResolveEnvSecretRefInReadOnlyPath({
|
||||
cfg: params.cfg,
|
||||
provider: ref.provider,
|
||||
id: ref.id,
|
||||
})
|
||||
) {
|
||||
return { token: "", tokenSource: "env", tokenStatus: "configured_unavailable" };
|
||||
}
|
||||
const envValue = process.env[ref.id];
|
||||
if (envValue && envValue.trim()) {
|
||||
return { token: envValue.trim(), tokenSource: "env", tokenStatus: "available" };
|
||||
}
|
||||
return { token: "", tokenSource: "env", tokenStatus: "configured_unavailable" };
|
||||
}
|
||||
const token = normalizeSecretInputString(params.value);
|
||||
if (token) {
|
||||
return { token, tokenSource: "config", tokenStatus: "available" };
|
||||
}
|
||||
if (hasConfiguredSecretInput(params.value)) {
|
||||
return { token: "", tokenSource: "config", tokenStatus: "configured_unavailable" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inspectTelegramAccountPrimary(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
envToken?: string | null;
|
||||
}): InspectedTelegramAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const merged = mergeTelegramAccountConfig(params.cfg, accountId);
|
||||
const enabled = params.cfg.channels?.telegram?.enabled !== false && merged.enabled !== false;
|
||||
|
||||
const accountConfig = resolveTelegramAccountConfig(params.cfg, accountId);
|
||||
const accountTokenFile = inspectTokenFile(accountConfig?.tokenFile);
|
||||
if (accountTokenFile) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
token: accountTokenFile.token,
|
||||
tokenSource: accountTokenFile.tokenSource,
|
||||
tokenStatus: accountTokenFile.tokenStatus,
|
||||
configured: accountTokenFile.tokenStatus !== "missing",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
const accountToken = inspectTokenValue({ cfg: params.cfg, value: accountConfig?.botToken });
|
||||
if (accountToken) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
token: accountToken.token,
|
||||
tokenSource: accountToken.tokenSource,
|
||||
tokenStatus: accountToken.tokenStatus,
|
||||
configured: accountToken.tokenStatus !== "missing",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
const channelTokenFile = inspectTokenFile(params.cfg.channels?.telegram?.tokenFile);
|
||||
if (channelTokenFile) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
token: channelTokenFile.token,
|
||||
tokenSource: channelTokenFile.tokenSource,
|
||||
tokenStatus: channelTokenFile.tokenStatus,
|
||||
configured: channelTokenFile.tokenStatus !== "missing",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
const channelToken = inspectTokenValue({
|
||||
cfg: params.cfg,
|
||||
value: params.cfg.channels?.telegram?.botToken,
|
||||
});
|
||||
if (channelToken) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
token: channelToken.token,
|
||||
tokenSource: channelToken.tokenSource,
|
||||
tokenStatus: channelToken.tokenStatus,
|
||||
configured: channelToken.tokenStatus !== "missing",
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
const envToken =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? (params.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim()
|
||||
: "";
|
||||
if (envToken) {
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
token: envToken,
|
||||
tokenSource: "env",
|
||||
tokenStatus: "available",
|
||||
configured: true,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
token: "",
|
||||
tokenSource: "none",
|
||||
tokenStatus: "missing",
|
||||
configured: false,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectTelegramAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
envToken?: string | null;
|
||||
}): InspectedTelegramAccount {
|
||||
return resolveAccountWithDefaultFallback({
|
||||
accountId: params.accountId,
|
||||
normalizeAccountId,
|
||||
resolvePrimary: (accountId) =>
|
||||
inspectTelegramAccountPrimary({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
envToken: params.envToken,
|
||||
}),
|
||||
hasCredential: (account) => account.tokenSource !== "none",
|
||||
resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg),
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { ChannelId } from "./plugins/types.js";
|
||||
|
||||
type DiscordInspectModule = typeof import("./read-only-account-inspect.discord.runtime.js");
|
||||
type SlackInspectModule = typeof import("./read-only-account-inspect.slack.runtime.js");
|
||||
type TelegramInspectModule = typeof import("./read-only-account-inspect.telegram.runtime.js");
|
||||
type TelegramInspectModule = typeof import("./read-only-account-inspect.telegram.js");
|
||||
|
||||
let discordInspectModulePromise: Promise<DiscordInspectModule> | undefined;
|
||||
let slackInspectModulePromise: Promise<SlackInspectModule> | undefined;
|
||||
@@ -20,7 +20,7 @@ function loadSlackInspectModule() {
|
||||
}
|
||||
|
||||
function loadTelegramInspectModule() {
|
||||
telegramInspectModulePromise ??= import("./read-only-account-inspect.telegram.runtime.js");
|
||||
telegramInspectModulePromise ??= import("./read-only-account-inspect.telegram.js");
|
||||
return telegramInspectModulePromise;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => (isDaemon ? daemonLoadedConfig : cliLoadedConfig),
|
||||
};
|
||||
},
|
||||
loadConfig: () => cliLoadedConfig,
|
||||
resolveConfigPath: (env: NodeJS.ProcessEnv, stateDir: string) => resolveConfigPath(env, stateDir),
|
||||
resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env),
|
||||
resolveStateDir: (env: NodeJS.ProcessEnv) => resolveStateDir(env),
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../infra/matrix-config-helpers.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
||||
|
||||
@@ -219,7 +219,14 @@ describe("doctor matrix provider helpers", () => {
|
||||
|
||||
try {
|
||||
const result = await runMatrixDoctorSequence({
|
||||
cfg: {},
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
env: process.env,
|
||||
shouldRepair: false,
|
||||
});
|
||||
@@ -234,4 +241,30 @@ describe("doctor matrix provider helpers", () => {
|
||||
cryptoSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips Matrix migration probes for unrelated configs", async () => {
|
||||
const matrixStateModule = await import("../../../infra/matrix-legacy-state.js");
|
||||
const matrixCryptoModule = await import("../../../infra/matrix-legacy-crypto.js");
|
||||
|
||||
const stateSpy = vi.spyOn(matrixStateModule, "detectLegacyMatrixState");
|
||||
const cryptoSpy = vi.spyOn(matrixCryptoModule, "detectLegacyMatrixCrypto");
|
||||
|
||||
try {
|
||||
const result = await runMatrixDoctorSequence({
|
||||
cfg: {
|
||||
gateway: { auth: { mode: "token", token: "123" } },
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
},
|
||||
env: {},
|
||||
shouldRepair: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ changeNotes: [], warningNotes: [] });
|
||||
expect(stateSpy).not.toHaveBeenCalled();
|
||||
expect(cryptoSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stateSpy.mockRestore();
|
||||
cryptoSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../../../infra/plugin-install-path-warnings.js";
|
||||
import { resolveBundledPluginInstallCommandHint } from "../../../plugins/bundled-sources.js";
|
||||
import { removePluginFromConfig } from "../../../plugins/uninstall.js";
|
||||
import { isRecord } from "../../../utils.js";
|
||||
import type { DoctorConfigMutationResult } from "../shared/config-mutation-state.js";
|
||||
|
||||
export function formatMatrixLegacyStatePreview(
|
||||
@@ -74,6 +75,34 @@ export async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Pro
|
||||
}).map((entry) => `- ${entry}`);
|
||||
}
|
||||
|
||||
function hasConfiguredMatrixChannel(cfg: OpenClawConfig): boolean {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
return isRecord(channels?.matrix);
|
||||
}
|
||||
|
||||
function hasConfiguredMatrixPluginSurface(cfg: OpenClawConfig): boolean {
|
||||
return Boolean(
|
||||
cfg.plugins?.installs?.matrix ||
|
||||
cfg.plugins?.entries?.matrix ||
|
||||
cfg.plugins?.allow?.includes("matrix") ||
|
||||
cfg.plugins?.deny?.includes("matrix"),
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredMatrixEnv(env: NodeJS.ProcessEnv): boolean {
|
||||
return Object.entries(env).some(
|
||||
([key, value]) => key.startsWith("MATRIX_") && typeof value === "string" && value.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function configMayNeedMatrixDoctorSequence(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
return (
|
||||
hasConfiguredMatrixChannel(cfg) ||
|
||||
hasConfiguredMatrixPluginSurface(cfg) ||
|
||||
hasConfiguredMatrixEnv(env)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a config mutation that removes stale Matrix plugin install/load-path
|
||||
* references left behind by the old bundled-plugin layout. When the install
|
||||
@@ -199,6 +228,16 @@ export async function runMatrixDoctorSequence(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
shouldRepair: boolean;
|
||||
}): Promise<{ changeNotes: string[]; warningNotes: string[] }> {
|
||||
const warningNotes: string[] = [];
|
||||
const changeNotes: string[] = [];
|
||||
const matrixInstallWarnings = await collectMatrixInstallPathWarnings(params.cfg);
|
||||
if (matrixInstallWarnings.length > 0) {
|
||||
warningNotes.push(matrixInstallWarnings.join("\n"));
|
||||
}
|
||||
if (!configMayNeedMatrixDoctorSequence(params.cfg, params.env)) {
|
||||
return { changeNotes, warningNotes };
|
||||
}
|
||||
|
||||
const matrixLegacyState = detectLegacyMatrixState({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
@@ -207,8 +246,6 @@ export async function runMatrixDoctorSequence(params: {
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
});
|
||||
const warningNotes: string[] = [];
|
||||
const changeNotes: string[] = [];
|
||||
|
||||
if (params.shouldRepair) {
|
||||
const matrixRepair = await applyMatrixDoctorRepair({
|
||||
@@ -232,10 +269,5 @@ export async function runMatrixDoctorSequence(params: {
|
||||
warningNotes.push(...formatMatrixLegacyCryptoPreview(matrixLegacyCrypto));
|
||||
}
|
||||
|
||||
const matrixInstallWarnings = await collectMatrixInstallPathWarnings(params.cfg);
|
||||
if (matrixInstallWarnings.length > 0) {
|
||||
warningNotes.push(matrixInstallWarnings.join("\n"));
|
||||
}
|
||||
|
||||
return { changeNotes, warningNotes };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import {
|
||||
inspectTelegramAccount,
|
||||
isNumericTelegramUserId,
|
||||
listTelegramAccountIds,
|
||||
normalizeTelegramAllowFromEntry,
|
||||
} from "../../../channels/read-only-account-inspect.telegram.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../../cli/command-secret-targets.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { TelegramNetworkConfig } from "../../../config/types.telegram.js";
|
||||
import { resolveTelegramAccount } from "../../../plugin-sdk/account-resolution.js";
|
||||
import {
|
||||
isNumericTelegramUserId,
|
||||
normalizeTelegramAllowFromEntry,
|
||||
inspectTelegramAccount,
|
||||
listTelegramAccountIds,
|
||||
lookupTelegramChatId,
|
||||
} from "../../../plugin-sdk/telegram.js";
|
||||
import { lookupTelegramChatId } from "../../../plugin-sdk/telegram.js";
|
||||
import { describeUnknownError } from "../../../secrets/shared.js";
|
||||
import { sanitizeForLog } from "../../../terminal/ansi.js";
|
||||
import { hasAllowFromEntries } from "../shared/allowlist.js";
|
||||
@@ -164,20 +163,25 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig)
|
||||
const lookupAccounts: ResolvedTelegramLookupAccount[] = [];
|
||||
const seenLookupAccounts = new Set<string>();
|
||||
for (const accountId of listTelegramAccountIds(resolvedConfig)) {
|
||||
let account: NonNullable<ReturnType<typeof resolveTelegramAccount>>;
|
||||
let inspected: ReturnType<typeof inspectTelegramAccount>;
|
||||
try {
|
||||
account = resolveTelegramAccount({ cfg: resolvedConfig, accountId });
|
||||
inspected = inspectTelegramAccount({ cfg: resolvedConfig, accountId });
|
||||
} catch (error) {
|
||||
tokenResolutionWarnings.push(
|
||||
`- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const token = account.tokenSource === "none" ? "" : account.token.trim();
|
||||
if (inspected.tokenStatus === "configured_unavailable") {
|
||||
tokenResolutionWarnings.push(
|
||||
`- Telegram account ${accountId}: failed to inspect bot token (configured but unavailable in this command path).`,
|
||||
);
|
||||
}
|
||||
const token = inspected.tokenSource === "none" ? "" : inspected.token.trim();
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
const network = account.config.network;
|
||||
const network = inspected.config.network;
|
||||
const cacheKey = `${token}::${JSON.stringify(network ?? {})}`;
|
||||
if (seenLookupAccounts.has(cacheKey)) {
|
||||
continue;
|
||||
|
||||
@@ -186,6 +186,36 @@ describe("applyPluginAutoEnable", () => {
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips auto-enable work for configs without channel or plugin-owned surfaces", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "ok",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "pi" }],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({
|
||||
gateway: {
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "ok",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "pi" }],
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores channels.modelByChannel for plugin auto-enable", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
|
||||
@@ -2,10 +2,10 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import {
|
||||
getChatChannelMeta,
|
||||
listChatChannels,
|
||||
normalizeChatChannelId,
|
||||
} from "../channels/registry.js";
|
||||
hasPotentialConfiguredChannels,
|
||||
listPotentialConfiguredChannelIds,
|
||||
} from "../channels/config-presence.js";
|
||||
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
|
||||
import {
|
||||
BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS,
|
||||
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS,
|
||||
@@ -283,24 +283,20 @@ function resolvePluginIdForChannel(
|
||||
return channelToPluginId.get(channelId) ?? channelId;
|
||||
}
|
||||
|
||||
function listKnownChannelPluginIds(): string[] {
|
||||
return listChatChannels().map((meta) => meta.id);
|
||||
function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
|
||||
return listPotentialConfiguredChannelIds(cfg, env).map(
|
||||
(channelId) => normalizeChatChannelId(channelId) ?? channelId,
|
||||
);
|
||||
}
|
||||
|
||||
function collectCandidateChannelIds(cfg: OpenClawConfig): string[] {
|
||||
const channelIds = new Set<string>(listKnownChannelPluginIds());
|
||||
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (!configuredChannels || typeof configuredChannels !== "object") {
|
||||
return Array.from(channelIds);
|
||||
function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
if (!entries || typeof entries !== "object") {
|
||||
return false;
|
||||
}
|
||||
for (const key of Object.keys(configuredChannels)) {
|
||||
if (key === "defaults" || key === "modelByChannel") {
|
||||
continue;
|
||||
}
|
||||
const normalizedBuiltIn = normalizeChatChannelId(key);
|
||||
channelIds.add(normalizedBuiltIn ?? key);
|
||||
}
|
||||
return Array.from(channelIds);
|
||||
return Object.values(entries).some(
|
||||
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webSearch),
|
||||
);
|
||||
}
|
||||
|
||||
function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
|
||||
@@ -319,6 +315,37 @@ function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function configMayNeedPluginAutoEnable(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
if (hasPotentialConfiguredChannels(cfg, env)) {
|
||||
return true;
|
||||
}
|
||||
if (resolveBrowserAutoEnableReason(cfg)) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true) {
|
||||
return true;
|
||||
}
|
||||
if (typeof cfg.acp?.backend === "string" && cfg.acp.backend.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (collectModelRefs(cfg).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (isRecord(cfg.tools?.web?.x_search as Record<string, unknown> | undefined)) {
|
||||
return true;
|
||||
}
|
||||
if (isRecord(cfg.plugins?.entries?.xai?.config) || hasConfiguredWebSearchPluginEntry(cfg)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function listContainsBrowser(value: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
@@ -380,7 +407,7 @@ function resolveConfiguredPlugins(
|
||||
const changes: PluginEnableChange[] = [];
|
||||
// Build reverse map: channel ID → plugin ID from installed plugin manifests.
|
||||
const channelToPluginId = buildChannelToPluginIdMap(registry);
|
||||
for (const channelId of collectCandidateChannelIds(cfg)) {
|
||||
for (const channelId of collectCandidateChannelIds(cfg, env)) {
|
||||
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
|
||||
if (isChannelConfigured(cfg, channelId, env)) {
|
||||
changes.push({ pluginId, reason: `${channelId} configured` });
|
||||
@@ -555,6 +582,9 @@ export function applyPluginAutoEnable(params: {
|
||||
manifestRegistry?: PluginManifestRegistry;
|
||||
}): PluginAutoEnableResult {
|
||||
const env = params.env ?? process.env;
|
||||
if (!configMayNeedPluginAutoEnable(params.config, env)) {
|
||||
return { config: params.config, changes: [] };
|
||||
}
|
||||
const registry =
|
||||
params.manifestRegistry ??
|
||||
(configMayNeedPluginManifestRegistry(params.config)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
|
||||
const { resolveRuntimePluginRegistryMock } = vi.hoisted(() => ({
|
||||
resolveRuntimePluginRegistryMock: vi.fn<
|
||||
@@ -15,14 +16,27 @@ vi.mock("../plugins/loader.js", () => ({
|
||||
let generateImage: typeof import("./runtime.js").generateImage;
|
||||
let listRuntimeImageGenerationProviders: typeof import("./runtime.js").listRuntimeImageGenerationProviders;
|
||||
|
||||
function setCompatibleActiveImageGenerationRegistry(
|
||||
pluginRegistry: ReturnType<typeof createEmptyPluginRegistry>,
|
||||
_cfg: OpenClawConfig,
|
||||
) {
|
||||
setActivePluginRegistry(pluginRegistry);
|
||||
}
|
||||
|
||||
describe("image-generation runtime helpers", () => {
|
||||
afterEach(() => {
|
||||
resolveRuntimePluginRegistryMock.mockReset();
|
||||
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
|
||||
resetPluginRuntimeStateForTest();
|
||||
vi.doUnmock("../plugins/loader.js");
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resetPluginRuntimeStateForTest();
|
||||
vi.doMock("../plugins/loader.js", () => ({
|
||||
resolveRuntimePluginRegistry: resolveRuntimePluginRegistryMock,
|
||||
}));
|
||||
({ generateImage, listRuntimeImageGenerationProviders } = await import("./runtime.js"));
|
||||
});
|
||||
|
||||
@@ -66,6 +80,7 @@ describe("image-generation runtime helpers", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
setCompatibleActiveImageGenerationRegistry(pluginRegistry, cfg);
|
||||
|
||||
const result = await generateImage({
|
||||
cfg,
|
||||
@@ -115,6 +130,7 @@ describe("image-generation runtime helpers", () => {
|
||||
},
|
||||
});
|
||||
resolveRuntimePluginRegistryMock.mockReturnValue(pluginRegistry);
|
||||
setCompatibleActiveImageGenerationRegistry(pluginRegistry, {} as OpenClawConfig);
|
||||
|
||||
expect(listRuntimeImageGenerationProviders()).toMatchObject([
|
||||
{
|
||||
@@ -174,6 +190,7 @@ describe("image-generation runtime helpers", () => {
|
||||
},
|
||||
);
|
||||
resolveRuntimePluginRegistryMock.mockReturnValue(pluginRegistry);
|
||||
setCompatibleActiveImageGenerationRegistry(pluginRegistry, {} as OpenClawConfig);
|
||||
|
||||
const promise = generateImage({ cfg: {} as OpenClawConfig, prompt: "draw a cat" });
|
||||
|
||||
@@ -204,6 +221,7 @@ describe("image-generation runtime helpers", () => {
|
||||
},
|
||||
});
|
||||
resolveRuntimePluginRegistryMock.mockReturnValue(pluginRegistry);
|
||||
setCompatibleActiveImageGenerationRegistry(pluginRegistry, {} as OpenClawConfig);
|
||||
|
||||
await expect(
|
||||
generateImage({ cfg: {} as OpenClawConfig, prompt: "draw a cat" }),
|
||||
|
||||
96
src/infra/heartbeat-runner.test-channel-plugins.ts
Normal file
96
src/infra/heartbeat-runner.test-channel-plugins.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
ChannelId,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
} from "../channels/plugins/types.js";
|
||||
import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js";
|
||||
import { resolveOutboundSendDep, type OutboundSendDeps } from "./outbound/send-deps.js";
|
||||
|
||||
type HeartbeatSendChannelId = "slack" | "telegram" | "whatsapp";
|
||||
type HeartbeatSendFn = (
|
||||
to: string,
|
||||
text: string,
|
||||
opts?: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
|
||||
function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): ChannelOutboundAdapter {
|
||||
return {
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ to, text, deps, cfg, accountId, replyToId, threadId, ...opts }) => {
|
||||
const send = resolveOutboundSendDep<HeartbeatSendFn>(deps as OutboundSendDeps, channelId);
|
||||
if (!send) {
|
||||
throw new Error(`Missing ${channelId} outbound send dependency`);
|
||||
}
|
||||
const baseOptions = {
|
||||
verbose: false,
|
||||
cfg,
|
||||
accountId,
|
||||
};
|
||||
const sendOptions =
|
||||
channelId === "telegram"
|
||||
? {
|
||||
...baseOptions,
|
||||
...(typeof threadId === "number" ? { messageThreadId: threadId } : {}),
|
||||
...(typeof replyToId === "string" ? { replyToMessageId: Number(replyToId) } : {}),
|
||||
}
|
||||
: {
|
||||
...baseOptions,
|
||||
...opts,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
...(threadId !== undefined ? { threadId } : {}),
|
||||
};
|
||||
return (await send(to, text, sendOptions)) as never;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHeartbeatChannelPlugin(params: {
|
||||
id: HeartbeatSendChannelId;
|
||||
label: string;
|
||||
docsPath: string;
|
||||
heartbeat?: ChannelPlugin["heartbeat"];
|
||||
}): ChannelPlugin {
|
||||
return {
|
||||
...createOutboundTestPlugin({
|
||||
id: params.id as ChannelId,
|
||||
label: params.label,
|
||||
docsPath: params.docsPath,
|
||||
outbound: createHeartbeatOutboundAdapter(params.id),
|
||||
}),
|
||||
...(params.heartbeat ? { heartbeat: params.heartbeat } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const heartbeatRunnerSlackPlugin = createHeartbeatChannelPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
});
|
||||
|
||||
export const heartbeatRunnerTelegramPlugin = createHeartbeatChannelPlugin({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
});
|
||||
|
||||
export const heartbeatRunnerWhatsAppPlugin = createHeartbeatChannelPlugin({
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
heartbeat: {
|
||||
checkReady: async ({ cfg, deps }) => {
|
||||
if (cfg.web?.enabled === false) {
|
||||
return { ok: false, reason: "whatsapp-disabled" };
|
||||
}
|
||||
const authExists = await (deps?.webAuthExists ?? (async () => true))();
|
||||
if (!authExists) {
|
||||
return { ok: false, reason: "whatsapp-not-linked" };
|
||||
}
|
||||
const listenerActive = deps?.hasActiveWebListener ? deps.hasActiveWebListener() : true;
|
||||
if (!listenerActive) {
|
||||
return { ok: false, reason: "whatsapp-not-running" };
|
||||
}
|
||||
return { ok: true, reason: "ok" };
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,47 +1,28 @@
|
||||
import { beforeEach } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createPluginRuntime, type PluginRuntime } from "../plugins/runtime/index.js";
|
||||
import { loadBundledPluginTestApiSync } from "../test-utils/bundled-plugin-public-surface.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
const { slackPlugin, setSlackRuntime } = loadBundledPluginTestApiSync<{
|
||||
slackPlugin: ChannelPlugin;
|
||||
setSlackRuntime: (runtime: PluginRuntime) => void;
|
||||
}>("slack");
|
||||
const { telegramPlugin, setTelegramRuntime } = loadBundledPluginTestApiSync<{
|
||||
telegramPlugin: ChannelPlugin;
|
||||
setTelegramRuntime: (runtime: PluginRuntime) => void;
|
||||
}>("telegram");
|
||||
const { whatsappPlugin, setWhatsAppRuntime } = loadBundledPluginTestApiSync<{
|
||||
whatsappPlugin: ChannelPlugin;
|
||||
setWhatsAppRuntime: (runtime: PluginRuntime) => void;
|
||||
}>("whatsapp");
|
||||
|
||||
const slackChannelPlugin = slackPlugin as unknown as ChannelPlugin;
|
||||
const telegramChannelPlugin = telegramPlugin as unknown as ChannelPlugin;
|
||||
const whatsappChannelPlugin = whatsappPlugin as unknown as ChannelPlugin;
|
||||
import {
|
||||
heartbeatRunnerSlackPlugin,
|
||||
heartbeatRunnerTelegramPlugin,
|
||||
heartbeatRunnerWhatsAppPlugin,
|
||||
} from "./heartbeat-runner.test-channel-plugins.js";
|
||||
|
||||
export function installHeartbeatRunnerTestRuntime(params?: { includeSlack?: boolean }): void {
|
||||
beforeEach(() => {
|
||||
const runtime = createPluginRuntime();
|
||||
setTelegramRuntime(runtime);
|
||||
setWhatsAppRuntime(runtime);
|
||||
if (params?.includeSlack) {
|
||||
setSlackRuntime(runtime);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "slack", plugin: slackChannelPlugin, source: "test" },
|
||||
{ pluginId: "whatsapp", plugin: whatsappChannelPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramChannelPlugin, source: "test" },
|
||||
{ pluginId: "slack", plugin: heartbeatRunnerSlackPlugin, source: "test" },
|
||||
{ pluginId: "whatsapp", plugin: heartbeatRunnerWhatsAppPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: heartbeatRunnerTelegramPlugin, source: "test" },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "whatsapp", plugin: whatsappChannelPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: telegramChannelPlugin, source: "test" },
|
||||
{ pluginId: "whatsapp", plugin: heartbeatRunnerWhatsAppPlugin, source: "test" },
|
||||
{ pluginId: "telegram", plugin: heartbeatRunnerTelegramPlugin, source: "test" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,18 +3,11 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { vi } from "vitest";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createPluginRuntime, type PluginRuntime } from "../plugins/runtime/index.js";
|
||||
import { loadBundledPluginTestApiSync } from "../test-utils/bundled-plugin-public-surface.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
const { telegramPlugin, setTelegramRuntime } = loadBundledPluginTestApiSync<{
|
||||
telegramPlugin: ChannelPlugin;
|
||||
setTelegramRuntime: (runtime: PluginRuntime) => void;
|
||||
}>("telegram");
|
||||
import { heartbeatRunnerTelegramPlugin } from "./heartbeat-runner.test-channel-plugins.js";
|
||||
|
||||
export type HeartbeatSessionSeed = {
|
||||
sessionId?: string;
|
||||
@@ -103,9 +96,9 @@ export async function withTempTelegramHeartbeatSandbox<T>(
|
||||
}
|
||||
|
||||
export function setupTelegramHeartbeatPluginRuntimeForTests() {
|
||||
const runtime = createPluginRuntime();
|
||||
setTelegramRuntime(runtime);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]),
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", plugin: heartbeatRunnerTelegramPlugin, source: "test" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
264
src/infra/matrix-config-helpers.ts
Normal file
264
src/infra/matrix-config-helpers.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
listCombinedAccountIds,
|
||||
listConfiguredAccountIds,
|
||||
resolveListedDefaultAccountId,
|
||||
resolveNormalizedAccountEntry,
|
||||
} from "../plugin-sdk/account-core.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "../routing/session-key.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const MATRIX_SCOPED_ENV_SUFFIXES = [
|
||||
"HOMESERVER",
|
||||
"USER_ID",
|
||||
"ACCESS_TOKEN",
|
||||
"PASSWORD",
|
||||
"DEVICE_ID",
|
||||
"DEVICE_NAME",
|
||||
] as const;
|
||||
const MATRIX_GLOBAL_ENV_KEYS = MATRIX_SCOPED_ENV_SUFFIXES.map((suffix) => `MATRIX_${suffix}`);
|
||||
const MATRIX_SCOPED_ENV_RE = new RegExp(`^MATRIX_(.+)_(${MATRIX_SCOPED_ENV_SUFFIXES.join("|")})$`);
|
||||
|
||||
export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
|
||||
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
|
||||
}
|
||||
|
||||
export function findMatrixAccountEntry(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): Record<string, unknown> | null {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
if (!accounts) {
|
||||
return null;
|
||||
}
|
||||
const entry = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
|
||||
return isRecord(entry) ? entry : null;
|
||||
}
|
||||
|
||||
export function resolveMatrixEnvAccountToken(accountId: string): string {
|
||||
return Array.from(normalizeAccountId(accountId))
|
||||
.map((char) =>
|
||||
/[a-z0-9]/.test(char)
|
||||
? char.toUpperCase()
|
||||
: `_X${char.codePointAt(0)?.toString(16).toUpperCase() ?? "00"}_`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function getMatrixScopedEnvVarNames(accountId: string): {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
password: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
} {
|
||||
const token = resolveMatrixEnvAccountToken(accountId);
|
||||
return {
|
||||
homeserver: `MATRIX_${token}_HOMESERVER`,
|
||||
userId: `MATRIX_${token}_USER_ID`,
|
||||
accessToken: `MATRIX_${token}_ACCESS_TOKEN`,
|
||||
password: `MATRIX_${token}_PASSWORD`,
|
||||
deviceId: `MATRIX_${token}_DEVICE_ID`,
|
||||
deviceName: `MATRIX_${token}_DEVICE_NAME`,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeMatrixEnvAccountToken(token: string): string | undefined {
|
||||
let decoded = "";
|
||||
for (let index = 0; index < token.length; ) {
|
||||
const hexEscape = /^_X([0-9A-F]+)_/.exec(token.slice(index));
|
||||
if (hexEscape) {
|
||||
const hex = hexEscape[1];
|
||||
const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN;
|
||||
if (!Number.isFinite(codePoint)) {
|
||||
return undefined;
|
||||
}
|
||||
decoded += String.fromCodePoint(codePoint);
|
||||
index += hexEscape[0].length;
|
||||
continue;
|
||||
}
|
||||
const char = token[index];
|
||||
if (!char || !/[A-Z0-9]/.test(char)) {
|
||||
return undefined;
|
||||
}
|
||||
decoded += char.toLowerCase();
|
||||
index += 1;
|
||||
}
|
||||
const normalized = normalizeOptionalAccountId(decoded);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveMatrixEnvAccountToken(normalized) === token ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function listMatrixEnvAccountIds(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of MATRIX_GLOBAL_ENV_KEYS) {
|
||||
if (typeof env[key] === "string" && env[key]?.trim()) {
|
||||
ids.add(DEFAULT_ACCOUNT_ID);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(env)) {
|
||||
const match = MATRIX_SCOPED_ENV_RE.exec(key);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const accountId = decodeMatrixEnvAccountToken(match[1]);
|
||||
if (accountId) {
|
||||
ids.add(accountId);
|
||||
}
|
||||
}
|
||||
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveConfiguredMatrixAccountIds(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
return listCombinedAccountIds({
|
||||
configuredAccountIds: listConfiguredAccountIds({
|
||||
accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined,
|
||||
normalizeAccountId,
|
||||
}),
|
||||
additionalAccountIds: listMatrixEnvAccountIds(env),
|
||||
fallbackAccountIdWhenEmpty: channel ? DEFAULT_ACCOUNT_ID : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatrixDefaultOrOnlyAccountId(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
return resolveListedDefaultAccountId({
|
||||
accountIds: resolveConfiguredMatrixAccountIds(cfg, env),
|
||||
configuredDefaultAccountId: configuredDefault,
|
||||
ambiguousFallbackAccountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
}
|
||||
|
||||
export function requiresExplicitMatrixDefaultAccount(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
|
||||
if (configuredAccountIds.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
|
||||
}
|
||||
|
||||
function sanitizeMatrixPathSegment(value: string): string {
|
||||
const cleaned = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return cleaned || "unknown";
|
||||
}
|
||||
|
||||
function resolveMatrixHomeserverKey(homeserver: string): string {
|
||||
try {
|
||||
const url = new URL(homeserver);
|
||||
if (url.host) {
|
||||
return sanitizeMatrixPathSegment(url.host);
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return sanitizeMatrixPathSegment(homeserver);
|
||||
}
|
||||
|
||||
function hashMatrixAccessToken(accessToken: string): string {
|
||||
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function resolveMatrixCredentialsFilename(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
return normalized === DEFAULT_ACCOUNT_ID ? "credentials.json" : `credentials-${normalized}.json`;
|
||||
}
|
||||
|
||||
function resolveMatrixCredentialsDir(stateDir: string): string {
|
||||
return path.join(stateDir, "credentials", "matrix");
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(params: {
|
||||
stateDir: string;
|
||||
accountId?: string | null;
|
||||
}): string {
|
||||
return path.join(
|
||||
resolveMatrixCredentialsDir(params.stateDir),
|
||||
resolveMatrixCredentialsFilename(params.accountId),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixLegacyFlatStoragePaths(stateDir: string): {
|
||||
rootDir: string;
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
} {
|
||||
const rootDir = path.join(stateDir, "matrix");
|
||||
return {
|
||||
rootDir,
|
||||
storagePath: path.join(rootDir, "bot-storage.json"),
|
||||
cryptoPath: path.join(rootDir, "crypto"),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountStorageRoot(params: {
|
||||
stateDir: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
accountId?: string | null;
|
||||
}): {
|
||||
rootDir: string;
|
||||
accountKey: string;
|
||||
tokenHash: string;
|
||||
} {
|
||||
const accountKey = sanitizeMatrixPathSegment(params.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const userKey = sanitizeMatrixPathSegment(params.userId);
|
||||
const serverKey = resolveMatrixHomeserverKey(params.homeserver);
|
||||
const tokenHash = hashMatrixAccessToken(params.accessToken);
|
||||
return {
|
||||
rootDir: path.join(
|
||||
params.stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
accountKey,
|
||||
`${serverKey}__${userKey}`,
|
||||
tokenHash,
|
||||
),
|
||||
accountKey,
|
||||
tokenHash,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugi
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "../plugin-sdk/matrix.js";
|
||||
} from "./matrix-config-helpers.js";
|
||||
import {
|
||||
resolveLegacyMatrixFlatStoreTarget,
|
||||
resolveMatrixMigrationAccountTarget,
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "../plugin-sdk/matrix.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-config-helpers.js";
|
||||
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
|
||||
|
||||
export type MatrixLegacyStateMigrationResult = {
|
||||
|
||||
@@ -2,18 +2,17 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
getMatrixScopedEnvVarNames,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixAccountStringValues,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../plugin-sdk/matrix.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
} from "./matrix-config-helpers.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
@@ -37,10 +36,70 @@ export type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & {
|
||||
|
||||
type MatrixLegacyFlatStoreKind = "state" | "encrypted state";
|
||||
|
||||
type MatrixResolvedStringField =
|
||||
| "homeserver"
|
||||
| "userId"
|
||||
| "accessToken"
|
||||
| "password"
|
||||
| "deviceId"
|
||||
| "deviceName";
|
||||
|
||||
type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
|
||||
|
||||
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
|
||||
|
||||
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
]);
|
||||
|
||||
function clean(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function resolveMatrixStringSourceValue(value: string | undefined): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
|
||||
return (
|
||||
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
|
||||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatrixAccountStringValues(params: {
|
||||
accountId: string;
|
||||
account?: MatrixStringSourceMap;
|
||||
scopedEnv?: MatrixStringSourceMap;
|
||||
channel?: MatrixStringSourceMap;
|
||||
globalEnv?: MatrixStringSourceMap;
|
||||
}): MatrixResolvedStringValues {
|
||||
const fields: MatrixResolvedStringField[] = [
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
"deviceName",
|
||||
];
|
||||
const resolved = {} as MatrixResolvedStringValues;
|
||||
|
||||
for (const field of fields) {
|
||||
resolved[field] =
|
||||
resolveMatrixStringSourceValue(params.account?.[field]) ||
|
||||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
|
||||
(shouldAllowBaseAuthFallback(params.accountId, field)
|
||||
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
|
||||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
|
||||
: "");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveScopedMatrixEnvConfig(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
|
||||
@@ -121,6 +121,13 @@ function expectSourceOmitsSnippet(subpath: string, snippet: string) {
|
||||
expect(readPluginSdkSource(subpath)).not.toContain(snippet);
|
||||
}
|
||||
|
||||
function expectSourceOmitsImportPattern(subpath: string, specifier: string) {
|
||||
const escapedSpecifier = specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const source = readPluginSdkSource(subpath);
|
||||
expect(source).not.toMatch(new RegExp(`\\bfrom\\s+["']${escapedSpecifier}["']`, "u"));
|
||||
expect(source).not.toMatch(new RegExp(`\\bimport\\(\\s*["']${escapedSpecifier}["']\\s*\\)`, "u"));
|
||||
}
|
||||
|
||||
describe("plugin-sdk subpath exports", () => {
|
||||
it("keeps the curated public list free of internal implementation subpaths", () => {
|
||||
for (const deniedSubpath of [
|
||||
@@ -613,8 +620,8 @@ describe("plugin-sdk subpath exports", () => {
|
||||
],
|
||||
});
|
||||
expectSourceOmitsSnippet("provider-setup", "./ollama-surface.js");
|
||||
expectSourceOmitsSnippet("provider-setup", "./vllm.js");
|
||||
expectSourceOmitsSnippet("provider-setup", "./sglang.js");
|
||||
expectSourceOmitsImportPattern("provider-setup", "./vllm.js");
|
||||
expectSourceOmitsImportPattern("provider-setup", "./sglang.js");
|
||||
expectSourceMentions("provider-auth", [
|
||||
"buildOauthProviderAuthResult",
|
||||
"generatePkceVerifierChallenge",
|
||||
@@ -665,6 +672,8 @@ describe("plugin-sdk subpath exports", () => {
|
||||
],
|
||||
omits: ["buildVllmProvider", "buildSglangProvider"],
|
||||
});
|
||||
expectSourceOmitsImportPattern("self-hosted-provider-setup", "./vllm.js");
|
||||
expectSourceOmitsImportPattern("self-hosted-provider-setup", "./sglang.js");
|
||||
expectSourceOmitsSnippet("agent-runtime", "./sglang.js");
|
||||
expectSourceOmitsSnippet("agent-runtime", "./vllm.js");
|
||||
expectSourceOmitsSnippet("xai-model-id", "./xai.js");
|
||||
|
||||
@@ -78,6 +78,15 @@ describe("bundled plugin metadata", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("loads tlon channel config metadata from the lightweight schema surface", () => {
|
||||
const tlon = listBundledPluginMetadata().find((entry) => entry.dirName === "tlon");
|
||||
expect(tlon?.manifest.channelConfigs?.tlon).toEqual(
|
||||
expect.objectContaining({
|
||||
schema: expect.objectContaining({ type: "object" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes test-only public surface artifacts", () => {
|
||||
listBundledPluginMetadata().forEach((entry) =>
|
||||
expectTestOnlyArtifactsExcluded(entry.publicSurfaceArtifacts ?? []),
|
||||
@@ -234,6 +243,10 @@ describe("bundled plugin metadata", () => {
|
||||
});
|
||||
writeJson(path.join(distRoot, "extensions", "alpha", "openclaw.plugin.json"), {
|
||||
id: "alpha",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
channels: ["alpha"],
|
||||
channelConfigs: {
|
||||
alpha: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
|
||||
const BROWSER_FIXTURE_MANIFEST = {
|
||||
id: "browser",
|
||||
enabledByDefault: true,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
|
||||
@@ -85,7 +85,6 @@ describe("buildOfficialChannelCatalog", () => {
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/whatsapp",
|
||||
localPath: bundledPluginRoot("whatsapp"),
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user