Merge branch 'main' into fix/optional-bundled-plugin-metadata-filter
This commit is contained in:
commit
7bdea632f1
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "openclaw/plugin-sdk/nextcloud-talk";
|
||||
export * from "../../src/plugin-sdk/nextcloud-talk.js";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)}`,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
24
package.json
24
package.json
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
154
src/commands/channels.remove.test.ts
Normal file
154
src/commands/channels.remove.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user