Merge branch 'main' into fix/optional-bundled-plugin-metadata-filter

This commit is contained in:
しんらてんせい 2026-03-19 15:22:52 +08:00 committed by GitHub
commit 7bdea632f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 826 additions and 477 deletions

View File

@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
- Agents/openai-responses: strip `prompt_cache_key` and `prompt_cache_retention` for non-OpenAI-compatible Responses endpoints while keeping them on direct OpenAI and Azure OpenAI paths, so third-party OpenAI-compatible providers no longer reject those requests with HTTP 400. (#49877) Thanks @ShaunTsai.
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete.
@ -158,6 +159,7 @@ Docs: https://docs.openclaw.ai
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.
- WhatsApp: stabilize inbound monitor and setup tests (#50007) Thanks @joshavant.
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant.
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
### Breaking
@ -170,6 +172,7 @@ Docs: https://docs.openclaw.ai
- Skills/image generation: remove the bundled `nano-banana-pro` skill wrapper. Use `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"` for the native Nano Banana-style path instead.
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras.
- Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702)
- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras.
## 2026.3.13

View File

@ -129,7 +129,13 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
fun setForeground(value: Boolean) {
foreground = value
runtimeRef.value?.setForeground(value)
val runtime =
if (value && prefs.onboardingCompleted.value) {
ensureRuntime()
} else {
runtimeRef.value
}
runtime?.setForeground(value)
}
fun setDisplayName(value: String) {

View File

@ -568,43 +568,8 @@ class NodeRuntime(
scope.launch(Dispatchers.Default) {
gateways.collect { list ->
if (list.isNotEmpty()) {
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
// UX parity with iOS: only set once when unset.
if (lastDiscoveredStableId.value.trim().isEmpty()) {
prefs.setLastDiscoveredStableId(list.first().stableId)
}
}
if (didAutoConnect) return@collect
if (_isConnected.value) return@collect
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) {
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
if (!manualTls.value) return@collect
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(GatewayEndpoint.manual(host = host, port = port))
}
return@collect
}
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return@collect
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(target)
seedLastDiscoveredGateway(list)
autoConnectIfNeeded()
}
}
@ -629,11 +594,53 @@ class NodeRuntime(
fun setForeground(value: Boolean) {
_isForeground.value = value
if (!value) {
if (value) {
reconnectPreferredGatewayOnForeground()
} else {
stopActiveVoiceSession()
}
}
private fun seedLastDiscoveredGateway(list: List<GatewayEndpoint>) {
if (list.isEmpty()) return
if (lastDiscoveredStableId.value.trim().isNotEmpty()) return
prefs.setLastDiscoveredStableId(list.first().stableId)
}
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isEmpty() || port !in 1..65535) return null
return GatewayEndpoint.manual(host = host, port = port)
}
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return null
val endpoint = gateways.value.firstOrNull { it.stableId == targetStableId } ?: return null
val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return null
return endpoint
}
private fun autoConnectIfNeeded() {
if (didAutoConnect) return
if (_isConnected.value) return
val endpoint = resolvePreferredGatewayEndpoint() ?: return
didAutoConnect = true
connect(endpoint)
}
private fun reconnectPreferredGatewayOnForeground() {
if (_isConnected.value) return
if (_pendingGatewayTrust.value != null) return
if (connectedEndpoint != null) {
refreshGatewayConnection()
return
}
resolvePreferredGatewayEndpoint()?.let(::connect)
}
fun setDisplayName(value: String) {
prefs.setDisplayName(value)
}

View File

@ -1 +1,6 @@
export * from "openclaw/plugin-sdk/copilot-proxy";
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type {
OpenClawPluginApi,
ProviderAuthContext,
ProviderAuthResult,
} from "openclaw/plugin-sdk/core";

View File

@ -1,10 +1,8 @@
import {
createAccountActionGate,
createAccountListHelpers,
} from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import {
normalizeAccountId,
resolveAccountEntry,
type OpenClawConfig,
type DiscordAccountConfig,
type DiscordActionConfig,

View File

@ -3,58 +3,21 @@ import { vi } from "vitest";
export const sendMock: MockFn = vi.fn();
export const reactMock: MockFn = vi.fn();
export const recordInboundSessionMock: MockFn = vi.fn();
export const updateLastRouteMock: MockFn = vi.fn();
export const dispatchMock: MockFn = vi.fn();
export const readAllowFromStoreMock: MockFn = vi.fn();
export const upsertPairingRequestMock: MockFn = vi.fn();
vi.mock("./send.js", () => ({
addRoleDiscord: vi.fn(),
banMemberDiscord: vi.fn(),
createChannelDiscord: vi.fn(),
createScheduledEventDiscord: vi.fn(),
createThreadDiscord: vi.fn(),
deleteChannelDiscord: vi.fn(),
deleteMessageDiscord: vi.fn(),
editChannelDiscord: vi.fn(),
editMessageDiscord: vi.fn(),
fetchChannelInfoDiscord: vi.fn(),
fetchChannelPermissionsDiscord: vi.fn(),
fetchMemberInfoDiscord: vi.fn(),
fetchMessageDiscord: vi.fn(),
fetchReactionsDiscord: vi.fn(),
fetchRoleInfoDiscord: vi.fn(),
fetchVoiceStatusDiscord: vi.fn(),
hasAnyGuildPermissionDiscord: vi.fn(),
kickMemberDiscord: vi.fn(),
listGuildChannelsDiscord: vi.fn(),
listGuildEmojisDiscord: vi.fn(),
listPinsDiscord: vi.fn(),
listScheduledEventsDiscord: vi.fn(),
listThreadsDiscord: vi.fn(),
moveChannelDiscord: vi.fn(),
pinMessageDiscord: vi.fn(),
reactMessageDiscord: async (...args: unknown[]) => {
reactMock(...args);
},
readMessagesDiscord: vi.fn(),
removeChannelPermissionDiscord: vi.fn(),
removeOwnReactionsDiscord: vi.fn(),
removeReactionDiscord: vi.fn(),
removeRoleDiscord: vi.fn(),
searchMessagesDiscord: vi.fn(),
sendDiscordComponentMessage: vi.fn(),
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
sendPollDiscord: vi.fn(),
sendStickerDiscord: vi.fn(),
sendVoiceMessageDiscord: vi.fn(),
setChannelPermissionDiscord: vi.fn(),
timeoutMemberDiscord: vi.fn(),
unpinMessageDiscord: vi.fn(),
uploadEmojiDiscord: vi.fn(),
uploadStickerDiscord: vi.fn(),
}));
vi.mock("./send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./send.js")>();
return {
...actual,
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
reactMessageDiscord: async (...args: unknown[]) => {
reactMock(...args);
},
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
@ -85,19 +48,10 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
readSessionUpdatedAt: vi.fn(() => undefined),
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),

View File

@ -58,28 +58,29 @@ const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn());
let lastDispatchCtx: Record<string, unknown> | undefined;
vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../src/security/dm-policy-shared.js")>();
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
return {
...actual,
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
readStoreAllowFromForDmPolicy: async (params: {
provider: string;
accountId: string;
dmPolicy?: string | null;
shouldRead?: boolean | null;
}) => {
if (params.shouldRead === false || params.dmPolicy === "allowlist") {
return [];
}
return await readAllowFromStoreMock(params.provider, params.accountId);
},
};
});
vi.mock("../../../../src/pairing/pairing-store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/pairing/pairing-store.js")>();
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../src/plugins/conversation-binding.js")>();
return {
...actual,
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
resolvePluginConversationBindingApprovalMock(...args),
buildPluginBindingResolvedText: (...args: unknown[]) =>
@ -87,14 +88,24 @@ vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal
};
});
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
};
});
// agent-components.ts can bind the core dispatcher via reply-runtime re-exports,
// so keep this direct mock to avoid hitting real embedded-agent dispatch in tests.
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => {
const actual =
await importOriginal<
@ -106,16 +117,16 @@ vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (import
};
});
vi.mock("../../../../src/channels/session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/channels/session.js")>();
vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
@ -123,8 +134,8 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
};
});
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
return {
...actual,
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
@ -189,13 +200,13 @@ describe("agent components", () => {
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledTimes(1);
expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE");
const pairingText = String(reply.mock.calls[0]?.[0]?.content ?? "");
expect(pairingText).toContain("Pairing code:");
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
expect(code).toBeDefined();
expect(pairingText).toContain(`openclaw pairing approve discord ${code}`);
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
provider: "discord",
accountId: "default",
dmPolicy: "pairing",
});
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
});
it("blocks DM interactions in allowlist mode when sender is not in configured allowFrom", async () => {
@ -229,11 +240,7 @@ describe("agent components", () => {
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
provider: "discord",
accountId: "default",
dmPolicy: "pairing",
});
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
});
it("allows DM component interactions in open mode without reading pairing store", async () => {
@ -831,10 +838,9 @@ describe("discord component interactions", () => {
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith({ components: [] });
expect(followUp).toHaveBeenCalledWith({
content: "Binding approved.",
content: expect.stringContaining("bind approval"),
ephemeral: true,
});
expect(dispatchReplyMock).not.toHaveBeenCalled();

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Google Chat extension.
// Keep this barrel thin and aligned with the curated plugin-sdk/googlechat surface.
export * from "openclaw/plugin-sdk/googlechat";
export * from "../../src/plugin-sdk/googlechat.js";

View File

@ -1 +1 @@
export * from "openclaw/plugin-sdk/nextcloud-talk";
export * from "../../src/plugin-sdk/nextcloud-talk.js";

View File

@ -1 +1,2 @@
export * from "openclaw/plugin-sdk/open-prose";
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";

View File

@ -1 +1,7 @@
export * from "openclaw/plugin-sdk/phone-control";
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type {
OpenClawPluginApi,
OpenClawPluginCommandDefinition,
OpenClawPluginService,
PluginCommandContext,
} from "openclaw/plugin-sdk/core";

View File

@ -1,5 +1,3 @@
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/infra-runtime";
import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime";
import type { MockFn } from "openclaw/plugin-sdk/testing";
import { beforeEach, vi } from "vitest";
import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js";
@ -73,6 +71,10 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
return {
...actual,
loadConfig: () => config,
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
};
});
@ -81,28 +83,51 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
return {
...actual,
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
dispatchInboundMessage: async (params: {
ctx: unknown;
cfg: unknown;
dispatcher: {
sendFinalReply: (payload: { text: string }) => boolean;
markComplete?: () => void;
waitForIdle?: () => Promise<void>;
};
}) => {
const resolved = await replyMock(params.ctx, {}, params.cfg);
const text = typeof resolved?.text === "string" ? resolved.text.trim() : "";
if (text) {
params.dispatcher.sendFinalReply({ text });
}
params.dispatcher.markComplete?.();
await params.dispatcher.waitForIdle?.();
return { queuedFinal: Boolean(text) };
},
};
});
vi.mock("./send.js", () => ({
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
sendTypingSignal: vi.fn().mockResolvedValue(true),
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
vi.mock("./send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./send.js")>();
return {
...actual,
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
sendTypingSignal: vi.fn().mockResolvedValue(true),
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
return {
...actual,
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
};
});
@ -129,7 +154,11 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
});
export function installSignalToolResultTestHooks() {
beforeEach(() => {
beforeEach(async () => {
const [{ resetInboundDedupe }, { resetSystemEventsForTest }] = await Promise.all([
import("openclaw/plugin-sdk/reply-runtime"),
import("openclaw/plugin-sdk/infra-runtime"),
]);
resetInboundDedupe();
config = {
messages: { responsePrefix: "PFX" },

View File

@ -1,23 +1,6 @@
import type { WebClient } from "@slack/web-api";
import { vi } from "vitest";
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => ({}),
};
});
vi.mock("./accounts.js", () => ({
resolveSlackAccount: () => ({
accountId: "default",
botToken: "xoxb-test",
botTokenSource: "config",
config: {},
}),
}));
export type SlackEditTestClient = WebClient & {
chat: {
update: ReturnType<typeof vi.fn>;
@ -33,8 +16,35 @@ export type SlackSendTestClient = WebClient & {
};
};
const slackBlockTestState = vi.hoisted(() => ({
account: {
accountId: "default",
botToken: "xoxb-test",
botTokenSource: "config",
config: {},
},
config: {},
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => slackBlockTestState.config,
};
});
vi.mock("./accounts.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./accounts.js")>();
return {
...actual,
resolveSlackAccount: () => slackBlockTestState.account,
};
});
// Kept for compatibility with existing tests; mocks install at module evaluation.
export function installSlackBlockTestMocks() {
// Backward compatible no-op. Mocks are hoisted at module scope.
return;
}
export function createSlackEditTestClient(): SlackEditTestClient {

View File

@ -202,37 +202,30 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
const replyResolver: typeof actual.getReplyFromConfig = (...args) =>
slackTestState.replyMock(...args) as ReturnType<typeof actual.getReplyFromConfig>;
return {
...actual,
dispatchInboundMessage: async (params: {
ctx: unknown;
replyOptions?: {
onReplyStart?: () => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | void;
};
dispatcher: {
sendFinalReply: (payload: unknown) => boolean;
waitForIdle: () => Promise<void>;
markComplete: () => void;
};
}) => {
const reply = await slackTestState.replyMock(params.ctx, {
...params.replyOptions,
onReplyStart:
params.replyOptions?.onReplyStart ?? params.replyOptions?.onAssistantMessageStart,
});
const queuedFinal = reply ? params.dispatcher.sendFinalReply(reply) : false;
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal,
counts: {
tool: 0,
block: 0,
final: queuedFinal ? 1 : 0,
},
};
},
getReplyFromConfig: replyResolver,
dispatchInboundMessage: (params: Parameters<typeof actual.dispatchInboundMessage>[0]) =>
actual.dispatchInboundMessage({
...params,
replyResolver,
}),
dispatchInboundMessageWithBufferedDispatcher: (
params: Parameters<typeof actual.dispatchInboundMessageWithBufferedDispatcher>[0],
) =>
actual.dispatchInboundMessageWithBufferedDispatcher({
...params,
replyResolver,
}),
dispatchInboundMessageWithDispatcher: (
params: Parameters<typeof actual.dispatchInboundMessageWithDispatcher>[0],
) =>
actual.dispatchInboundMessageWithDispatcher({
...params,
replyResolver,
}),
};
});
@ -246,9 +239,13 @@ vi.mock("./resolve-users.js", () => ({
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./send.js", () => ({
sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args),
}));
vi.mock("./send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./send.js")>();
return {
...actual,
sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
@ -265,20 +262,12 @@ vi.mock("@slack/bolt", () => {
const { handlers, client: slackClient } = ensureSlackTestRuntime();
class App {
client = slackClient;
receiver = {
client: {
on: vi.fn(),
off: vi.fn(),
},
};
event(name: string, handler: SlackHandler) {
handlers.set(name, handler);
}
command = vi.fn();
action = vi.fn();
options = vi.fn();
view = vi.fn();
shortcut = vi.fn();
command() {
/* no-op */
}
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined);
}

View File

@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
resolveAgentRouteMock: vi.fn(),
finalizeInboundContextMock: vi.fn(),
resolveConversationLabelMock: vi.fn(),
createChannelReplyPipelineMock: vi.fn(),
createReplyPrefixOptionsMock: vi.fn(),
recordSessionMetaFromInboundMock: vi.fn(),
resolveStorePathMock: vi.fn(),
}));
@ -43,27 +43,16 @@ vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
return {
...actual,
resolveConversationLabel: (...args: unknown[]) => mocks.resolveConversationLabelMock(...args),
createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args),
recordInboundSessionMetaSafe: (...args: unknown[]) =>
mocks.recordSessionMetaFromInboundMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/channel-reply-pipeline")>();
return {
...actual,
createChannelReplyPipeline: (...args: unknown[]) =>
mocks.createChannelReplyPipelineMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
recordSessionMetaFromInbound: (...args: unknown[]) =>
mocks.recordSessionMetaFromInboundMock(...args),
resolveStorePath: (...args: unknown[]) => mocks.resolveStorePathMock(...args),
};
});
@ -75,7 +64,7 @@ type SlashHarnessMocks = {
resolveAgentRouteMock: ReturnType<typeof vi.fn>;
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
resolveConversationLabelMock: ReturnType<typeof vi.fn>;
createChannelReplyPipelineMock: ReturnType<typeof vi.fn>;
createReplyPrefixOptionsMock: ReturnType<typeof vi.fn>;
recordSessionMetaFromInboundMock: ReturnType<typeof vi.fn>;
resolveStorePathMock: ReturnType<typeof vi.fn>;
};
@ -95,7 +84,7 @@ export function resetSlackSlashMocks() {
});
mocks.finalizeInboundContextMock.mockReset().mockImplementation((ctx: unknown) => ctx);
mocks.resolveConversationLabelMock.mockReset().mockReturnValue(undefined);
mocks.createChannelReplyPipelineMock.mockReset().mockReturnValue({ onModelSelected: () => {} });
mocks.createReplyPrefixOptionsMock.mockReset().mockReturnValue({ onModelSelected: () => {} });
mocks.recordSessionMetaFromInboundMock.mockReset().mockResolvedValue(undefined);
mocks.resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions.json");
}

View File

@ -1,7 +1,7 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js";
vi.mock("../../../../src/auto-reply/commands-registry.js", () => {
vi.mock("./slash-commands.runtime.js", () => {
const usageCommand = { key: "usage", nativeName: "usage" };
const reportCommand = { key: "report", nativeName: "report" };
const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" };
@ -180,21 +180,26 @@ vi.mock("../../../../src/auto-reply/commands-registry.js", () => {
});
type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise<void>;
let registerSlackMonitorSlashCommands: RegisterFn;
let registerSlackMonitorSlashCommandsPromise: Promise<RegisterFn> | undefined;
async function loadRegisterSlackMonitorSlashCommands(): Promise<RegisterFn> {
registerSlackMonitorSlashCommandsPromise ??= import("./slash.js").then((module) => {
const typed = module as unknown as {
registerSlackMonitorSlashCommands: RegisterFn;
};
return typed.registerSlackMonitorSlashCommands;
});
return await registerSlackMonitorSlashCommandsPromise;
}
const { dispatchMock } = getSlackSlashMocks();
beforeAll(async () => {
({ registerSlackMonitorSlashCommands } = (await import("./slash.js")) as unknown as {
registerSlackMonitorSlashCommands: RegisterFn;
});
});
beforeEach(() => {
resetSlackSlashMocks();
});
async function registerCommands(ctx: unknown, account: unknown) {
const registerSlackMonitorSlashCommands = await loadRegisterSlackMonitorSlashCommands();
await registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
}

View File

@ -1 +1,2 @@
export * from "openclaw/plugin-sdk/talk-voice";
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";

View File

@ -59,6 +59,30 @@ const TELEGRAM_TEST_TIMINGS = {
textFragmentGapMs: 30,
} as const;
async function withIsolatedStateDirAsync<T>(fn: () => Promise<T>): Promise<T> {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-state-"));
return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
try {
return await fn();
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
}
async function withConfigPathAsync<T>(cfg: unknown, fn: () => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-cfg-"));
const configPath = path.join(dir, "openclaw.json");
fs.writeFileSync(configPath, JSON.stringify(cfg), "utf-8");
return await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, async () => {
try {
return await fn();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
}
describe("createTelegramBot", () => {
beforeAll(() => {
process.env.TZ = "UTC";
@ -250,107 +274,115 @@ describe("createTelegramBot", () => {
const cases = [
{
name: "new unknown sender",
upsertResults: [{ code: "PAIRME12", created: true }],
messages: ["hello"],
expectedSendCount: 1,
expectPairingText: true,
pairingUpsertResults: [{ code: "PAIRCODE", created: true }],
},
{
name: "already pending request",
upsertResults: [
{ code: "PAIRME12", created: true },
{ code: "PAIRME12", created: false },
],
messages: ["hello", "hello again"],
expectedSendCount: 1,
expectPairingText: false,
pairingUpsertResults: [
{ code: "PAIRCODE", created: true },
{ code: "PAIRCODE", created: false },
],
},
] as const;
for (const testCase of cases) {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
await withIsolatedStateDirAsync(async () => {
for (const [index, testCase] of cases.entries()) {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockClear();
let pairingUpsertCall = 0;
upsertChannelPairingRequest.mockImplementation(async () => {
const result =
testCase.pairingUpsertResults[
Math.min(pairingUpsertCall, testCase.pairingUpsertResults.length - 1)
];
pairingUpsertCall += 1;
return result;
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const senderId = Number(`${Date.now()}${index}`.slice(-9));
for (const text of testCase.messages) {
await handler({
message: {
chat: { id: 1234, type: "private" },
text,
date: 1736380800,
from: { id: senderId, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
}
expect(replySpy, testCase.name).not.toHaveBeenCalled();
expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount);
expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234);
const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
expect(pairingText, testCase.name).toContain(`Your Telegram user id: ${senderId}`);
expect(pairingText, testCase.name).toContain("Pairing code:");
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
expect(code, testCase.name).toBeDefined();
expect(pairingText, testCase.name).toContain(`openclaw pairing approve telegram ${code}`);
expect(pairingText, testCase.name).not.toContain("<code>");
}
});
});
it("blocks unauthorized DM media before download and sends pairing reply", async () => {
await withIsolatedStateDirAsync(async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockClear();
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true });
for (const result of testCase.upsertResults) {
upsertChannelPairingRequest.mockResolvedValueOnce(result);
}
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const senderId = Number(`${Date.now()}01`.slice(-9));
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
for (const text of testCase.messages) {
await handler({
message: {
chat: { id: 1234, type: "private" },
text,
message_id: 410,
date: 1736380800,
from: { id: 999, username: "random" },
photo: [{ file_id: "p1" }],
from: { id: senderId, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
getFile: getFileSpy,
});
}
expect(replySpy, testCase.name).not.toHaveBeenCalled();
expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount);
if (testCase.expectPairingText) {
expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234);
const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
expect(pairingText, testCase.name).toContain("Your Telegram user id: 999");
expect(pairingText, testCase.name).toContain("Pairing code:");
expect(pairingText, testCase.name).toContain("PAIRME12");
expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram PAIRME12");
expect(pairingText, testCase.name).not.toContain("<code>");
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
}
});
it("blocks unauthorized DM media before download and sends pairing reply", async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
message_id: 410,
date: 1736380800,
photo: [{ file_id: "p1" }],
from: { id: 999, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: getFileSpy,
});
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("blocks DM media downloads completely when dmPolicy is disabled", async () => {
loadConfig.mockReturnValue({
@ -393,48 +425,51 @@ describe("createTelegramBot", () => {
}
});
it("blocks unauthorized DM media groups before any photo download", async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
message_id: 412,
media_group_id: "dm-album-1",
date: 1736380800,
photo: [{ file_id: "p1" }],
from: { id: 999, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: getFileSpy,
await withIsolatedStateDirAsync(async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const senderId = Number(`${Date.now()}02`.slice(-9));
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
message_id: 412,
media_group_id: "dm-album-1",
date: 1736380800,
photo: [{ file_id: "p1" }],
from: { id: senderId, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: getFileSpy,
});
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
});
it("triggers typing cue via onReplyStart", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
@ -851,13 +886,15 @@ describe("createTelegramBot", () => {
});
it("routes DMs by telegram accountId binding", async () => {
loadConfig.mockReturnValue({
const config = {
channels: {
telegram: {
allowFrom: ["*"],
accounts: {
opie: {
botToken: "tok-opie",
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
@ -868,27 +905,30 @@ describe("createTelegramBot", () => {
match: { channel: "telegram", accountId: "opie" },
},
],
};
loadConfig.mockReturnValue(config);
await withConfigPathAsync(config, async () => {
createTelegramBot({ token: "tok", accountId: "opie" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "private" },
from: { id: 999, username: "testuser" },
text: "hello",
date: 1736380800,
message_id: 42,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.AccountId).toBe("opie");
expect(payload.SessionKey).toBe("agent:opie:main");
});
createTelegramBot({ token: "tok", accountId: "opie" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "private" },
from: { id: 999, username: "testuser" },
text: "hello",
date: 1736380800,
message_id: 42,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.AccountId).toBe("opie");
expect(payload.SessionKey).toBe("agent:opie:main");
});
it("reloads DM routing bindings between messages without recreating the bot", async () => {
@ -1192,26 +1232,28 @@ describe("createTelegramBot", () => {
];
for (const testCase of cases) {
resetHarnessSpies();
loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
await withConfigPathAsync(testCase.config, async () => {
resetHarnessSpies();
loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
from: { id: 999, username: "testuser" },
text: testCase.text,
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
from: { id: 999, username: "testuser" },
text: testCase.text,
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
}
});
@ -1907,7 +1949,7 @@ describe("createTelegramBot", () => {
}),
"utf-8",
);
loadConfig.mockReturnValue({
const config = {
channels: {
telegram: {
groupPolicy: "open",
@ -1924,23 +1966,26 @@ describe("createTelegramBot", () => {
},
],
session: { store: storePath },
};
loadConfig.mockReturnValue(config);
await withConfigPathAsync(config, async () => {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "group", title: "Routing" },
from: { id: 999, username: "ops" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "group", title: "Routing" },
from: { id: 999, username: "ops" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("applies topic skill filters and system prompts", async () => {

View File

@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { tagTelegramNetworkError } from "./network-errors.js";
type MonitorTelegramOpts = import("./monitor.js").MonitorTelegramOpts;
@ -110,7 +109,8 @@ function makeRecoverableFetchError() {
});
}
function makeTaggedPollingFetchError() {
async function makeTaggedPollingFetchError() {
const { tagTelegramNetworkError } = await import("./network-errors.js");
const err = makeRecoverableFetchError();
tagTelegramNetworkError(err, {
method: "getUpdates",
@ -180,24 +180,41 @@ async function runMonitorAndCaptureStartupOrder(params?: { persistedOffset?: num
function mockRunOnceWithStalledPollingRunner(): {
stop: ReturnType<typeof vi.fn<() => void | Promise<void>>>;
waitForTaskStart: () => Promise<void>;
} {
let running = true;
let releaseTask: (() => void) | undefined;
let releaseBeforeTaskStart = false;
let signalTaskStarted: (() => void) | undefined;
const taskStarted = new Promise<void>((resolve) => {
signalTaskStarted = resolve;
});
const stop = vi.fn(async () => {
running = false;
releaseTask?.();
if (releaseTask) {
releaseTask();
return;
}
releaseBeforeTaskStart = true;
});
runSpy.mockImplementationOnce(() =>
makeRunnerStub({
task: () =>
new Promise<void>((resolve) => {
signalTaskStarted?.();
releaseTask = resolve;
if (releaseBeforeTaskStart) {
resolve();
}
}),
stop,
isRunning: () => running,
}),
);
return { stop };
return {
stop,
waitForTaskStart: () => taskStarted,
};
}
function expectRecoverableRetryState(
@ -533,16 +550,17 @@ describe("monitorTelegramProvider (grammY)", () => {
it("force-restarts polling when unhandled network rejection stalls runner", async () => {
const { monitorTelegramProvider } = await import("./monitor.js");
const abort = new AbortController();
const { stop } = mockRunOnceWithStalledPollingRunner();
mockRunOnceAndAbort(abort);
const firstCycle = mockRunOnceWithStalledPollingRunner();
mockRunOnceWithStalledPollingRunner();
const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
emitUnhandledRejection(makeTaggedPollingFetchError());
expect(emitUnhandledRejection(await makeTaggedPollingFetchError())).toBe(true);
expect(firstCycle.stop).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(2));
abort.abort();
await monitor;
expect(stop.mock.calls.length).toBeGreaterThanOrEqual(1);
expectRecoverableRetryState(2);
});
@ -578,16 +596,17 @@ describe("monitorTelegramProvider (grammY)", () => {
it("aborts the active Telegram fetch when unhandled network rejection forces restart", async () => {
const { monitorTelegramProvider } = await import("./monitor.js");
const abort = new AbortController();
const { stop } = mockRunOnceWithStalledPollingRunner();
const { stop, waitForTaskStart } = mockRunOnceWithStalledPollingRunner();
mockRunOnceAndAbort(abort);
const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
await vi.waitFor(() => expect(createTelegramBotCalls.length).toBeGreaterThanOrEqual(1));
await waitForTaskStart();
const firstSignal = createTelegramBotCalls[0]?.fetchAbortSignal;
expect(firstSignal).toBeInstanceOf(AbortSignal);
expect((firstSignal as AbortSignal).aborted).toBe(false);
emitUnhandledRejection(makeTaggedPollingFetchError());
emitUnhandledRejection(await makeTaggedPollingFetchError());
await monitor;
expect((firstSignal as AbortSignal).aborted).toBe(true);

View File

@ -71,12 +71,23 @@ vi.mock("../../../../src/infra/heartbeat-events.js", () => ({
resolveIndicatorType: (status: string) => `indicator:${status}`,
}));
vi.mock("../../../../src/logging.js", () => ({
getChildLogger: () => ({
info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
}),
}));
vi.mock("../../../../src/logging.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/logging.js")>();
const createStubLogger = () => ({
info: () => undefined,
warn: () => undefined,
error: () => undefined,
child: createStubLogger,
});
return {
...actual,
getChildLogger: () => ({
info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
}),
createSubsystemLogger: () => createStubLogger(),
};
});
vi.mock("openclaw/plugin-sdk/state-paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/state-paths")>();
@ -125,10 +136,14 @@ vi.mock("../reconnect.js", () => ({
newConnectionId: () => "run-1",
}));
vi.mock("../send.js", () => ({
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
sendReactionWhatsApp: vi.fn(async () => undefined),
}));
vi.mock("../send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.js")>();
return {
...actual,
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
sendReactionWhatsApp: vi.fn(async () => undefined),
};
});
vi.mock("../session.js", () => ({
formatError: (err: unknown) => `ERR:${String(err)}`,

View File

@ -34,15 +34,21 @@ export function resetLoadConfigMock() {
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => {
const mockModule = Object.create(null) as Record<string, unknown>;
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
Object.defineProperty(mockModule, "loadConfig", {
configurable: true,
enumerable: true,
writable: true,
value: () => {
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
if (typeof getter === "function") {
return getter();
}
return DEFAULT_CONFIG;
},
});
Object.assign(mockModule, {
updateLastRoute: async (params: {
storePath: string;
sessionKey: string;
@ -68,7 +74,8 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
},
recordSessionMetaFromInbound: async () => undefined,
resolveStorePath: actual.resolveStorePath,
};
});
return mockModule;
});
// Some web modules live under `src/web/auto-reply/*` and import config via a different
@ -79,16 +86,21 @@ vi.mock("../../config/config.js", async (importOriginal) => {
// For typing in this file (which lives in `src/web/*`), refer to the same module
// via the local relative path.
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => {
const mockModule = Object.create(null) as Record<string, unknown>;
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
Object.defineProperty(mockModule, "loadConfig", {
configurable: true,
enumerable: true,
writable: true,
value: () => {
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
if (typeof getter === "function") {
return getter();
}
return DEFAULT_CONFIG;
},
};
});
return mockModule;
});
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {

View File

@ -185,10 +185,6 @@
"types": "./dist/plugin-sdk/discord-core.d.ts",
"default": "./dist/plugin-sdk/discord-core.js"
},
"./plugin-sdk/copilot-proxy": {
"types": "./dist/plugin-sdk/copilot-proxy.d.ts",
"default": "./dist/plugin-sdk/copilot-proxy.js"
},
"./plugin-sdk/feishu": {
"types": "./dist/plugin-sdk/feishu.d.ts",
"default": "./dist/plugin-sdk/feishu.js"
@ -245,18 +241,6 @@
"types": "./dist/plugin-sdk/imessage-core.d.ts",
"default": "./dist/plugin-sdk/imessage-core.js"
},
"./plugin-sdk/open-prose": {
"types": "./dist/plugin-sdk/open-prose.d.ts",
"default": "./dist/plugin-sdk/open-prose.js"
},
"./plugin-sdk/phone-control": {
"types": "./dist/plugin-sdk/phone-control.d.ts",
"default": "./dist/plugin-sdk/phone-control.js"
},
"./plugin-sdk/qwen-portal-auth": {
"types": "./dist/plugin-sdk/qwen-portal-auth.d.ts",
"default": "./dist/plugin-sdk/qwen-portal-auth.js"
},
"./plugin-sdk/signal": {
"types": "./dist/plugin-sdk/signal.d.ts",
"default": "./dist/plugin-sdk/signal.js"
@ -461,6 +445,10 @@
"types": "./dist/plugin-sdk/request-url.d.ts",
"default": "./dist/plugin-sdk/request-url.js"
},
"./plugin-sdk/qwen-portal-auth": {
"types": "./dist/plugin-sdk/qwen-portal-auth.d.ts",
"default": "./dist/plugin-sdk/qwen-portal-auth.js"
},
"./plugin-sdk/webhook-ingress": {
"types": "./dist/plugin-sdk/webhook-ingress.d.ts",
"default": "./dist/plugin-sdk/webhook-ingress.js"
@ -485,10 +473,6 @@
"types": "./dist/plugin-sdk/synology-chat.d.ts",
"default": "./dist/plugin-sdk/synology-chat.js"
},
"./plugin-sdk/talk-voice": {
"types": "./dist/plugin-sdk/talk-voice.d.ts",
"default": "./dist/plugin-sdk/talk-voice.js"
},
"./plugin-sdk/thread-ownership": {
"types": "./dist/plugin-sdk/thread-ownership.d.ts",
"default": "./dist/plugin-sdk/thread-ownership.js"

View File

@ -36,7 +36,6 @@
"telegram-core",
"discord",
"discord-core",
"copilot-proxy",
"feishu",
"google",
"googlechat",
@ -51,9 +50,6 @@
"slack-core",
"imessage",
"imessage-core",
"open-prose",
"phone-control",
"qwen-portal-auth",
"signal",
"whatsapp",
"whatsapp-shared",
@ -105,13 +101,13 @@
"secret-input-runtime",
"secret-input-schema",
"request-url",
"qwen-portal-auth",
"webhook-ingress",
"webhook-path",
"runtime-store",
"secret-input",
"signal-core",
"synology-chat",
"talk-voice",
"thread-ownership",
"tlon",
"twitch",

View File

@ -2291,4 +2291,83 @@ describe("applyExtraParamsToAgent", () => {
expect(run().store).toBe(false);
},
);
it("strips prompt cache fields for non-OpenAI openai-responses endpoints", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "custom-proxy",
applyModelId: "some-model",
model: {
api: "openai-responses",
provider: "custom-proxy",
id: "some-model",
baseUrl: "https://my-proxy.example.com/v1",
} as unknown as Model<"openai-responses">,
payload: {
store: false,
prompt_cache_key: "session-xyz",
prompt_cache_retention: "24h",
},
});
expect(payload).not.toHaveProperty("prompt_cache_key");
expect(payload).not.toHaveProperty("prompt_cache_retention");
});
it("keeps prompt cache fields for direct OpenAI openai-responses endpoints", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "https://api.openai.com/v1",
} as unknown as Model<"openai-responses">,
payload: {
store: false,
prompt_cache_key: "session-123",
prompt_cache_retention: "24h",
},
});
expect(payload.prompt_cache_key).toBe("session-123");
expect(payload.prompt_cache_retention).toBe("24h");
});
it("keeps prompt cache fields for direct Azure OpenAI openai-responses endpoints", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "azure-openai-responses",
applyModelId: "gpt-4o",
model: {
api: "openai-responses",
provider: "azure-openai-responses",
id: "gpt-4o",
baseUrl: "https://example.openai.azure.com/openai/v1",
} as unknown as Model<"openai-responses">,
payload: {
store: false,
prompt_cache_key: "session-azure",
prompt_cache_retention: "24h",
},
});
expect(payload.prompt_cache_key).toBe("session-azure");
expect(payload.prompt_cache_retention).toBe("24h");
});
it("keeps prompt cache fields when openai-responses baseUrl is omitted", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
} as unknown as Model<"openai-responses">,
payload: {
store: false,
prompt_cache_key: "session-default",
prompt_cache_retention: "24h",
},
});
expect(payload.prompt_cache_key).toBe("session-default");
expect(payload.prompt_cache_retention).toBe("24h");
});
});

View File

@ -154,10 +154,23 @@ function shouldStripResponsesStore(
return OPENAI_RESPONSES_APIS.has(model.api) && model.compat?.supportsStore === false;
}
function shouldStripResponsesPromptCache(model: { api?: unknown; baseUrl?: unknown }): boolean {
if (typeof model.api !== "string" || !OPENAI_RESPONSES_APIS.has(model.api)) {
return false;
}
// Missing baseUrl means pi-ai will use the default OpenAI endpoint, so keep
// prompt cache fields for that direct path.
if (typeof model.baseUrl !== "string" || !model.baseUrl.trim()) {
return false;
}
return !isDirectOpenAIBaseUrl(model.baseUrl);
}
function applyOpenAIResponsesPayloadOverrides(params: {
payloadObj: Record<string, unknown>;
forceStore: boolean;
stripStore: boolean;
stripPromptCache: boolean;
useServerCompaction: boolean;
compactThreshold: number;
}): void {
@ -167,6 +180,10 @@ function applyOpenAIResponsesPayloadOverrides(params: {
if (params.stripStore) {
delete params.payloadObj.store;
}
if (params.stripPromptCache) {
delete params.payloadObj.prompt_cache_key;
delete params.payloadObj.prompt_cache_retention;
}
if (params.useServerCompaction && params.payloadObj.context_management === undefined) {
params.payloadObj.context_management = [
{
@ -297,7 +314,8 @@ export function createOpenAIResponsesContextManagementWrapper(
const forceStore = shouldForceResponsesStore(model);
const useServerCompaction = shouldEnableOpenAIResponsesServerCompaction(model, extraParams);
const stripStore = shouldStripResponsesStore(model, forceStore);
if (!forceStore && !useServerCompaction && !stripStore) {
const stripPromptCache = shouldStripResponsesPromptCache(model);
if (!forceStore && !useServerCompaction && !stripStore && !stripPromptCache) {
return underlying(model, context, options);
}
@ -313,6 +331,7 @@ export function createOpenAIResponsesContextManagementWrapper(
payloadObj: payload as Record<string, unknown>,
forceStore,
stripStore,
stripPromptCache,
useServerCompaction,
compactThreshold,
});

View File

@ -0,0 +1,154 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import {
ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel,
} from "./channel-setup/plugin-install.js";
import { configMocks } from "./channels.mock-harness.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const catalogMocks = vi.hoisted(() => ({
listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []),
}));
vi.mock("../channels/plugins/catalog.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../channels/plugins/catalog.js")>();
return {
...actual,
listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
};
});
vi.mock("./channel-setup/plugin-install.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./channel-setup/plugin-install.js")>();
return {
...actual,
ensureChannelSetupPluginInstalled: vi.fn(async ({ cfg }) => ({ cfg, installed: true })),
loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(() => createTestRegistry()),
};
});
const runtime = createTestRuntime();
let channelsRemoveCommand: typeof import("./channels.js").channelsRemoveCommand;
describe("channelsRemoveCommand", () => {
beforeAll(async () => {
({ channelsRemoveCommand } = await import("./channels.js"));
});
beforeEach(() => {
configMocks.readConfigFileSnapshot.mockClear();
configMocks.writeConfigFile.mockClear();
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
catalogMocks.listChannelPluginCatalogEntries.mockClear();
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]);
vi.mocked(ensureChannelSetupPluginInstalled).mockClear();
vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({
cfg,
installed: true,
}));
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockClear();
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
createTestRegistry(),
);
setActivePluginRegistry(createTestRegistry());
});
it("removes an external channel account after installing its plugin on demand", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
msteams: {
enabled: true,
tenantId: "tenant-1",
},
},
},
});
const catalogEntry: ChannelPluginCatalogEntry = {
id: "msteams",
pluginId: "@openclaw/msteams-plugin",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
install: {
npmSpec: "@openclaw/msteams",
},
};
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
const scopedPlugin = {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
}),
config: {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
}).config,
deleteAccount: vi.fn(({ cfg }: { cfg: Record<string, unknown> }) => {
const channels = (cfg.channels as Record<string, unknown> | undefined) ?? {};
const nextChannels = { ...channels };
delete nextChannels.msteams;
return {
...cfg,
channels: nextChannels,
};
}),
},
};
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel)
.mockReturnValueOnce(createTestRegistry())
.mockReturnValueOnce(
createTestRegistry([
{
pluginId: "@openclaw/msteams-plugin",
plugin: scopedPlugin,
source: "test",
},
]),
);
await channelsRemoveCommand(
{
channel: "msteams",
account: "default",
delete: true,
},
runtime,
{ hasFlags: true },
);
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
expect.objectContaining({
entry: catalogEntry,
}),
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "msteams",
pluginId: "@openclaw/msteams-plugin",
}),
);
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
expect.not.objectContaining({
channels: expect.objectContaining({
msteams: expect.anything(),
}),
}),
);
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
});

View File

@ -8,6 +8,7 @@ import { type OpenClawConfig, writeConfigFile } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js";
import { type ChatChannel, channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
export type ChannelsRemoveOptions = {
@ -29,14 +30,16 @@ export async function channelsRemoveCommand(
runtime: RuntimeEnv = defaultRuntime,
params?: { hasFlags?: boolean },
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) {
const loadedCfg = await requireValidConfig(runtime);
if (!loadedCfg) {
return;
}
let cfg = loadedCfg;
const useWizard = shouldUseWizard(params);
const prompter = useWizard ? createClackPrompter() : null;
let channel: ChatChannel | null = normalizeChannelId(opts.channel);
const rawChannel = opts.channel?.trim() ?? "";
let channel: ChatChannel | null = normalizeChannelId(rawChannel);
let accountId = normalizeAccountId(opts.account);
const deleteConfig = Boolean(opts.delete);
@ -73,15 +76,16 @@ export async function channelsRemoveCommand(
return;
}
} else {
if (!channel) {
if (!rawChannel) {
runtime.error("Channel is required. Use --channel <name>.");
runtime.exit(1);
return;
}
if (!deleteConfig) {
const confirm = createClackPrompter();
const channelPromptLabel = channel ? channelLabel(channel) : rawChannel;
const ok = await confirm.confirm({
message: `Disable ${channelLabel(channel)} account "${accountId}"? (keeps config)`,
message: `Disable ${channelPromptLabel} account "${accountId}"? (keeps config)`,
initialValue: true,
});
if (!ok) {
@ -90,7 +94,20 @@ export async function channelsRemoveCommand(
}
}
const plugin = getChannelPlugin(channel);
const resolvedPluginState =
!useWizard && rawChannel
? await resolveInstallableChannelPlugin({
cfg,
runtime,
rawChannel,
allowInstall: true,
})
: null;
if (resolvedPluginState?.configChanged) {
cfg = resolvedPluginState.cfg;
}
channel = resolvedPluginState?.channelId ?? channel;
const plugin = resolvedPluginState?.plugin ?? (channel ? getChannelPlugin(channel) : undefined);
if (!plugin) {
runtime.error(`Unknown channel: ${channel}`);
runtime.exit(1);