merge: resolve conflicts with upstream/main
This commit is contained in:
commit
7c4cf4952b
@ -111,6 +111,7 @@
|
||||
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
|
||||
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- Do not switch CI `pnpm test` lanes back to Vitest `vmForks` by default without fresh green evidence on current `main`; keep CI on `forks` unless explicitly re-validated.
|
||||
- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs.
|
||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
- Full kit + what’s covered: `docs/help/testing.md`.
|
||||
|
||||
@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc.
|
||||
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
|
||||
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
|
||||
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
|
||||
|
||||
@ -1076,6 +1076,7 @@
|
||||
"group": "Extensions",
|
||||
"pages": [
|
||||
"plugins/building-extensions",
|
||||
"plugins/sdk-migration",
|
||||
"plugins/architecture",
|
||||
"plugins/community",
|
||||
"plugins/bundles",
|
||||
|
||||
144
docs/plugins/sdk-migration.md
Normal file
144
docs/plugins/sdk-migration.md
Normal file
@ -0,0 +1,144 @@
|
||||
---
|
||||
title: "Plugin SDK Migration"
|
||||
summary: "Migrate from openclaw/plugin-sdk/compat to focused subpath imports"
|
||||
read_when:
|
||||
- You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning
|
||||
- You are updating a plugin from the monolithic plugin-sdk import to scoped subpaths
|
||||
- You maintain an external OpenClaw plugin
|
||||
---
|
||||
|
||||
# Plugin SDK Migration
|
||||
|
||||
OpenClaw is migrating from a single monolithic `openclaw/plugin-sdk/compat` barrel
|
||||
to **focused subpath imports** (`openclaw/plugin-sdk/<subpath>`). This page explains
|
||||
what changed, why, and how to migrate.
|
||||
|
||||
## Why this change
|
||||
|
||||
The monolithic compat barrel re-exported everything from a single entry point.
|
||||
This caused:
|
||||
|
||||
- **Slow startup**: importing one helper pulled in dozens of unrelated modules.
|
||||
- **Circular dependency risk**: broad re-exports made it easy to create import cycles.
|
||||
- **Unclear API surface**: no way to tell which exports were stable vs internal.
|
||||
|
||||
Focused subpaths fix all three: each subpath is a small, self-contained module
|
||||
with a clear purpose.
|
||||
|
||||
## What triggers the warning
|
||||
|
||||
If your plugin imports from the compat barrel, you will see:
|
||||
|
||||
```
|
||||
[OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED] Warning: openclaw/plugin-sdk/compat is
|
||||
deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/<subpath> imports.
|
||||
```
|
||||
|
||||
The compat barrel still works at runtime. This is a deprecation warning, not an
|
||||
error. But new plugins **must not** use it, and existing plugins should migrate
|
||||
before compat is removed.
|
||||
|
||||
## How to migrate
|
||||
|
||||
### Step 1: Find compat imports
|
||||
|
||||
Search your extension for imports from the compat path:
|
||||
|
||||
```bash
|
||||
grep -r "plugin-sdk/compat" extensions/my-plugin/
|
||||
```
|
||||
|
||||
### Step 2: Replace with focused subpaths
|
||||
|
||||
Each export from compat maps to a specific subpath. Replace the import source:
|
||||
|
||||
```typescript
|
||||
// Before (compat barrel)
|
||||
import {
|
||||
createChannelReplyPipeline,
|
||||
createPluginRuntimeStore,
|
||||
resolveControlCommandGate,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
|
||||
// After (focused subpaths)
|
||||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
```
|
||||
|
||||
### Step 3: Verify
|
||||
|
||||
Run the build and tests:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm test -- extensions/my-plugin/
|
||||
```
|
||||
|
||||
## Subpath reference
|
||||
|
||||
| Subpath | Purpose | Key exports |
|
||||
| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` |
|
||||
| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` |
|
||||
| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
|
||||
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |
|
||||
| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter`, `createScopedChannelConfigAdapter` |
|
||||
| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types |
|
||||
| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` |
|
||||
| `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` |
|
||||
| `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities |
|
||||
| `plugin-sdk/channel-send-result` | Send result types | Reply result types |
|
||||
| `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` |
|
||||
| `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase`, `formatNormalizedAllowFromEntries` |
|
||||
| `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` |
|
||||
| `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` |
|
||||
| `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers |
|
||||
| `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities |
|
||||
| `plugin-sdk/reply-payload` | Message reply types | Reply payload types |
|
||||
| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers |
|
||||
| `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` |
|
||||
| `plugin-sdk/testing` | Test utilities | Test helpers and mocks |
|
||||
|
||||
Use the narrowest subpath that has what you need. If you cannot find an export,
|
||||
check the source at `src/plugin-sdk/` or ask in Discord.
|
||||
|
||||
## Compat barrel removal timeline
|
||||
|
||||
- **Now**: compat barrel emits a deprecation warning at runtime.
|
||||
- **Next major release**: compat barrel will be removed. Plugins still using it will
|
||||
fail to import.
|
||||
|
||||
Bundled plugins (under `extensions/`) have already been migrated. External plugins
|
||||
should migrate before the next major release.
|
||||
|
||||
## Suppressing the warning temporarily
|
||||
|
||||
If you need to suppress the warning while migrating:
|
||||
|
||||
```bash
|
||||
OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run
|
||||
```
|
||||
|
||||
This is a temporary escape hatch, not a permanent solution.
|
||||
|
||||
## Internal barrel pattern
|
||||
|
||||
Within your extension, use local barrel files (`api.ts`, `runtime-api.ts`) for
|
||||
internal code sharing instead of importing through the plugin SDK:
|
||||
|
||||
```typescript
|
||||
// extensions/my-plugin/api.ts — public contract for this extension
|
||||
export { MyConfig } from "./src/config.js";
|
||||
export { MyRuntime } from "./src/runtime.js";
|
||||
```
|
||||
|
||||
Never import your own extension back through `openclaw/plugin-sdk/<your-extension>`
|
||||
from production files. That path is for external consumers only. See
|
||||
[Building Extensions](/plugins/building-extensions#step-4-use-local-barrels-for-internal-imports).
|
||||
|
||||
## Related
|
||||
|
||||
- [Building Extensions](/plugins/building-extensions)
|
||||
- [Plugin Architecture](/plugins/architecture)
|
||||
- [Plugin Manifest](/plugins/manifest)
|
||||
@ -11,8 +11,9 @@ title: "Tests"
|
||||
|
||||
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
|
||||
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
|
||||
- `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for faster startup. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
|
||||
- `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for local runs with enough memory. CI stays on `forks` unless explicitly overridden. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
|
||||
- `pnpm test`: runs the full wrapper. It keeps only a small behavioral override manifest in git, then uses a checked-in timing snapshot to peel the heaviest measured unit files into dedicated lanes.
|
||||
- Files marked `singletonIsolated` no longer spawn one fresh Vitest process each by default. The wrapper batches them into dedicated `forks` lanes with `maxWorkers=1`, which preserves isolation from `unit-fast` while cutting process startup overhead. Tune lane count with `OPENCLAW_TEST_SINGLETON_ISOLATED_LANES=<n>`.
|
||||
- `pnpm test:channels`: runs channel-heavy suites.
|
||||
- `pnpm test:extensions`: runs extension/plugin suites.
|
||||
- `pnpm test:perf:update-timings`: refreshes the checked-in slow-file timing snapshot used by `scripts/test-parallel.mjs`.
|
||||
|
||||
@ -1 +1,38 @@
|
||||
export * from "../../src/plugin-sdk/acpx.js";
|
||||
export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime";
|
||||
export {
|
||||
AcpRuntimeError,
|
||||
registerAcpRuntimeBackend,
|
||||
unregisterAcpRuntimeBackend,
|
||||
} from "openclaw/plugin-sdk/acp-runtime";
|
||||
export type {
|
||||
AcpRuntime,
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeDoctorReport,
|
||||
AcpRuntimeEnsureInput,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnInput,
|
||||
AcpSessionUpdateTag,
|
||||
} from "openclaw/plugin-sdk/acp-runtime";
|
||||
export type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginConfigSchema,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
WindowsSpawnProgram,
|
||||
WindowsSpawnProgramCandidate,
|
||||
WindowsSpawnResolution,
|
||||
} from "openclaw/plugin-sdk/windows-spawn";
|
||||
export {
|
||||
applyWindowsSpawnProgramPolicy,
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgramCandidate,
|
||||
} from "openclaw/plugin-sdk/windows-spawn";
|
||||
export {
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
} from "openclaw/plugin-sdk/provider-env-vars";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Feishu extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/feishu.js";
|
||||
export * from "openclaw/plugin-sdk/feishu";
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { markdownToText, truncateText } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { withTrustedWebToolsEndpoint } from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
normalizeCacheKey,
|
||||
postTrustedWebToolsJson,
|
||||
readCache,
|
||||
readResponseText,
|
||||
resolveCacheTtlMs,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
@ -29,7 +28,6 @@ const SCRAPE_CACHE = new Map<
|
||||
>();
|
||||
const DEFAULT_SEARCH_COUNT = 5;
|
||||
const DEFAULT_SCRAPE_MAX_CHARS = 50_000;
|
||||
const DEFAULT_ERROR_MAX_BYTES = 64_000;
|
||||
|
||||
type FirecrawlSearchItem = {
|
||||
title: string;
|
||||
@ -88,51 +86,6 @@ function resolveSiteName(urlRaw: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
async function postFirecrawlJson(params: {
|
||||
baseUrl: string;
|
||||
pathname: "/v2/search" | "/v2/scrape";
|
||||
apiKey: string;
|
||||
body: Record<string, unknown>;
|
||||
timeoutSeconds: number;
|
||||
errorLabel: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const endpoint = resolveEndpoint(params.baseUrl, params.pathname);
|
||||
return await withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES });
|
||||
throw new Error(
|
||||
`${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`,
|
||||
);
|
||||
}
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (payload.success === false) {
|
||||
const error =
|
||||
typeof payload.error === "string"
|
||||
? payload.error
|
||||
: typeof payload.message === "string"
|
||||
? payload.message
|
||||
: "unknown error";
|
||||
throw new Error(`${params.errorLabel} API error: ${error}`);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSearchItems(payload: Record<string, unknown>): FirecrawlSearchItem[] {
|
||||
const candidates = [
|
||||
payload.data,
|
||||
@ -279,14 +232,28 @@ export async function runFirecrawlSearch(
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const payload = await postFirecrawlJson({
|
||||
baseUrl,
|
||||
pathname: "/v2/search",
|
||||
apiKey,
|
||||
body,
|
||||
timeoutSeconds,
|
||||
errorLabel: "Firecrawl Search",
|
||||
});
|
||||
const payload = await postTrustedWebToolsJson(
|
||||
{
|
||||
url: resolveEndpoint(baseUrl, "/v2/search"),
|
||||
timeoutSeconds,
|
||||
apiKey,
|
||||
body,
|
||||
errorLabel: "Firecrawl Search",
|
||||
},
|
||||
async (response) => {
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (payload.success === false) {
|
||||
const error =
|
||||
typeof payload.error === "string"
|
||||
? payload.error
|
||||
: typeof payload.message === "string"
|
||||
? payload.message
|
||||
: "unknown error";
|
||||
throw new Error(`Firecrawl Search API error: ${error}`);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
);
|
||||
const result = buildSearchPayload({
|
||||
query: params.query,
|
||||
provider: "firecrawl",
|
||||
@ -409,22 +376,24 @@ export async function runFirecrawlScrape(
|
||||
return { ...cached.value, cached: true };
|
||||
}
|
||||
|
||||
const payload = await postFirecrawlJson({
|
||||
baseUrl,
|
||||
pathname: "/v2/scrape",
|
||||
apiKey,
|
||||
timeoutSeconds,
|
||||
errorLabel: "Firecrawl",
|
||||
body: {
|
||||
url: params.url,
|
||||
formats: ["markdown"],
|
||||
onlyMainContent,
|
||||
timeout: timeoutSeconds * 1000,
|
||||
maxAge: maxAgeMs,
|
||||
proxy,
|
||||
storeInCache,
|
||||
const payload = await postTrustedWebToolsJson(
|
||||
{
|
||||
url: resolveEndpoint(baseUrl, "/v2/scrape"),
|
||||
timeoutSeconds,
|
||||
apiKey,
|
||||
errorLabel: "Firecrawl",
|
||||
body: {
|
||||
url: params.url,
|
||||
formats: ["markdown"],
|
||||
onlyMainContent,
|
||||
timeout: timeoutSeconds * 1000,
|
||||
maxAge: maxAgeMs,
|
||||
proxy,
|
||||
storeInCache,
|
||||
},
|
||||
},
|
||||
});
|
||||
async (response) => (await response.json()) as Record<string, unknown>,
|
||||
);
|
||||
const result = parseFirecrawlScrapePayload({
|
||||
payload,
|
||||
url: params.url,
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "../../src/plugin-sdk/google.js";
|
||||
export { normalizeGoogleModelId, parseGeminiAuth } from "openclaw/plugin-sdk/provider-google";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Google Chat extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/googlechat.js";
|
||||
export * from "openclaw/plugin-sdk/googlechat";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled IRC extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../../src/plugin-sdk/irc.js";
|
||||
export * from "openclaw/plugin-sdk/irc";
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
// Private runtime barrel for the bundled LINE extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/line.js";
|
||||
export { resolveExactLineGroupConfigKey } from "../../src/plugin-sdk/line-core.js";
|
||||
export * from "openclaw/plugin-sdk/line";
|
||||
export { resolveExactLineGroupConfigKey } from "openclaw/plugin-sdk/line-core";
|
||||
export {
|
||||
setSetupChannelEnabled,
|
||||
splitSetupEntries,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
} from "../../src/plugin-sdk/line-core.js";
|
||||
} from "openclaw/plugin-sdk/line-core";
|
||||
|
||||
66
extensions/line/src/channel-shared.ts
Normal file
66
extensions/line/src/channel-shared.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import type { ChannelPlugin } from "../api.js";
|
||||
import {
|
||||
resolveLineAccount,
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "../runtime-api.js";
|
||||
import { lineConfigAdapter } from "./config-adapter.js";
|
||||
import { LineChannelConfigSchema } from "./config-schema.js";
|
||||
|
||||
export const lineChannelMeta = {
|
||||
id: "line",
|
||||
label: "LINE",
|
||||
selectionLabel: "LINE (Messaging API)",
|
||||
detailLabel: "LINE Bot",
|
||||
docsPath: "/channels/line",
|
||||
docsLabel: "line",
|
||||
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
systemImage: "message.fill",
|
||||
} as const;
|
||||
|
||||
export const lineChannelPluginCommon = {
|
||||
meta: {
|
||||
...lineChannelMeta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: LineChannelConfigSchema,
|
||||
config: {
|
||||
...lineConfigAdapter,
|
||||
isConfigured: (account: ResolvedLineAccount) =>
|
||||
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
describeAccount: (account: ResolvedLineAccount) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
}),
|
||||
},
|
||||
} satisfies Pick<
|
||||
ChannelPlugin<ResolvedLineAccount>,
|
||||
"meta" | "capabilities" | "reload" | "configSchema" | "config"
|
||||
>;
|
||||
|
||||
export function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean {
|
||||
const resolved = resolveLineAccount({ cfg, accountId });
|
||||
return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim());
|
||||
}
|
||||
|
||||
export function parseLineAllowFromId(raw: string): string | null {
|
||||
const trimmed = raw.trim().replace(/^line:(?:user:)?/i, "");
|
||||
if (!/^U[a-f0-9]{32}$/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../runtime-api.js";
|
||||
@ -1,52 +1,11 @@
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
LineConfigSchema,
|
||||
type ChannelPlugin,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { lineConfigAdapter } from "./config-adapter.js";
|
||||
import { type ChannelPlugin, type ResolvedLineAccount } from "../api.js";
|
||||
import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const meta = {
|
||||
id: "line",
|
||||
label: "LINE",
|
||||
selectionLabel: "LINE (Messaging API)",
|
||||
detailLabel: "LINE Bot",
|
||||
docsPath: "/channels/line",
|
||||
docsLabel: "line",
|
||||
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
systemImage: "message.fill",
|
||||
} as const;
|
||||
|
||||
export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
id: "line",
|
||||
meta: {
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||
config: {
|
||||
...lineConfigAdapter,
|
||||
isConfigured: (account) =>
|
||||
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
}),
|
||||
},
|
||||
...lineChannelPluginCommon,
|
||||
setupWizard: lineSetupWizard,
|
||||
setup: lineSetupAdapter,
|
||||
};
|
||||
|
||||
@ -9,12 +9,10 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
clearAccountEntryFields,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
LineConfigSchema,
|
||||
processLineMessage,
|
||||
type ChannelPlugin,
|
||||
type ChannelStatusIssue,
|
||||
@ -23,24 +21,12 @@ import {
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "../api.js";
|
||||
import { lineConfigAdapter } from "./config-adapter.js";
|
||||
import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
|
||||
// LINE channel metadata
|
||||
const meta = {
|
||||
id: "line",
|
||||
label: "LINE",
|
||||
selectionLabel: "LINE (Messaging API)",
|
||||
detailLabel: "LINE Bot",
|
||||
docsPath: "/channels/line",
|
||||
docsLabel: "line",
|
||||
blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.",
|
||||
systemImage: "message.fill",
|
||||
};
|
||||
|
||||
const resolveLineDmPolicy = createScopedDmSecurityResolver<ResolvedLineAccount>({
|
||||
channelKey: "line",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
@ -63,10 +49,7 @@ const collectLineSecurityWarnings =
|
||||
|
||||
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
id: "line",
|
||||
meta: {
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
...lineChannelPluginCommon,
|
||||
pairing: createTextPairingAdapter({
|
||||
idLabel: "lineUserId",
|
||||
message: "OpenClaw: your access has been approved.",
|
||||
@ -83,29 +66,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||
});
|
||||
},
|
||||
}),
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
reactions: false,
|
||||
threads: false,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.line"] },
|
||||
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||
setupWizard: lineSetupWizard,
|
||||
config: {
|
||||
...lineConfigAdapter,
|
||||
isConfigured: (account) =>
|
||||
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||
tokenSource: account.tokenSource ?? undefined,
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveLineDmPolicy,
|
||||
collectWarnings: collectLineSecurityWarnings,
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
resolveLineAccount,
|
||||
type OpenClawConfig,
|
||||
type ResolvedLineAccount,
|
||||
} from "../runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/line-core";
|
||||
|
||||
export function normalizeLineAllowFrom(entry: string): string {
|
||||
return entry.replace(/^line:(?:user:)?/i, "");
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "openclaw/plugin-sdk/line-core";
|
||||
|
||||
type LineGroupContext = {
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
listLineAccountIds,
|
||||
normalizeAccountId,
|
||||
resolveLineAccount,
|
||||
type LineConfig,
|
||||
} from "../runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/line-core";
|
||||
import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup";
|
||||
|
||||
const channel = "line" as const;
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup";
|
||||
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
resolveLineAccount,
|
||||
@ -7,7 +5,9 @@ import {
|
||||
splitSetupEntries,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
} from "../runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/line-core";
|
||||
import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup";
|
||||
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import {
|
||||
isLineConfigured,
|
||||
listLineAccountIds,
|
||||
|
||||
@ -1 +1,12 @@
|
||||
export * from "../../src/plugin-sdk/lobster.js";
|
||||
export { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
AnyAgentTool,
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginToolContext,
|
||||
OpenClawPluginToolFactory,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
applyWindowsSpawnProgramPolicy,
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgramCandidate,
|
||||
} from "openclaw/plugin-sdk/windows-spawn";
|
||||
|
||||
@ -3,3 +3,4 @@
|
||||
// matrix-js-sdk during plain runtime-api import.
|
||||
export * from "./src/auth-precedence.js";
|
||||
export * from "./helper-api.js";
|
||||
export * from "./thread-bindings-runtime.js";
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { isAutoLinkedFileRef } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
@ -10,38 +11,6 @@ const md = new MarkdownIt({
|
||||
md.enable("strikethrough");
|
||||
|
||||
const { escapeHtml } = md.utils;
|
||||
|
||||
/**
|
||||
* Keep bare file references like README.md from becoming external http:// links.
|
||||
* Telegram already hardens this path; Matrix should not turn common code/docs
|
||||
* filenames into clickable registrar-style URLs either.
|
||||
*/
|
||||
const FILE_EXTENSIONS_WITH_TLD = new Set(["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"]);
|
||||
|
||||
function isAutoLinkedFileRef(href: string, label: string): boolean {
|
||||
const stripped = href.replace(/^https?:\/\//i, "");
|
||||
if (stripped !== label) {
|
||||
return false;
|
||||
}
|
||||
const dotIndex = label.lastIndexOf(".");
|
||||
if (dotIndex < 1) {
|
||||
return false;
|
||||
}
|
||||
const ext = label.slice(dotIndex + 1).toLowerCase();
|
||||
if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
const segments = label.split("/");
|
||||
if (segments.length > 1) {
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
if (segments[i]?.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSuppressAutoLink(
|
||||
tokens: Parameters<NonNullable<typeof md.renderer.rules.link_open>>[0],
|
||||
idx: number,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import type {
|
||||
BindingTargetKind,
|
||||
SessionBindingRecord,
|
||||
@ -74,32 +75,7 @@ export function resolveEffectiveBindingExpiry(params: {
|
||||
expiresAt?: number;
|
||||
reason?: "idle-expired" | "max-age-expired";
|
||||
} {
|
||||
const idleTimeoutMs =
|
||||
typeof params.record.idleTimeoutMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
|
||||
: params.defaultIdleTimeoutMs;
|
||||
const maxAgeMs =
|
||||
typeof params.record.maxAgeMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.maxAgeMs))
|
||||
: params.defaultMaxAgeMs;
|
||||
const inactivityExpiresAt =
|
||||
idleTimeoutMs > 0
|
||||
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
|
||||
: undefined;
|
||||
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
|
||||
|
||||
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
|
||||
return inactivityExpiresAt <= maxAgeExpiresAt
|
||||
? { expiresAt: inactivityExpiresAt, reason: "idle-expired" }
|
||||
: { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
if (inactivityExpiresAt != null) {
|
||||
return { expiresAt: inactivityExpiresAt, reason: "idle-expired" };
|
||||
}
|
||||
if (maxAgeExpiresAt != null) {
|
||||
return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
return {};
|
||||
return resolveThreadBindingLifecycle(params);
|
||||
}
|
||||
|
||||
export function toSessionBindingRecord(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Mattermost extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/mattermost.js";
|
||||
export * from "openclaw/plugin-sdk/mattermost";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Microsoft Teams extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/msteams.js";
|
||||
export * from "openclaw/plugin-sdk/msteams";
|
||||
|
||||
@ -141,8 +141,8 @@ describe("resolveGraphChatId", () => {
|
||||
}),
|
||||
);
|
||||
// Should filter by user AAD object ID
|
||||
const [callUrl] = (fetchFn.mock.calls[0] ?? []) as unknown[];
|
||||
expect(String(callUrl)).toContain("user-aad-object-id-123");
|
||||
const callUrl = (fetchFn.mock.calls[0] as unknown as [string, unknown])[0];
|
||||
expect(callUrl).toContain("user-aad-object-id-123");
|
||||
expect(result).toBe("19:dm-chat-id@unq.gbl.spaces");
|
||||
});
|
||||
|
||||
|
||||
@ -50,11 +50,14 @@ const runtimeStub: PluginRuntime = createPluginRuntimeMock({
|
||||
},
|
||||
});
|
||||
|
||||
const noopUpdateActivity = async () => {};
|
||||
const noopDeleteActivity = async () => {};
|
||||
|
||||
const createNoopAdapter = (): MSTeamsAdapter => ({
|
||||
continueConversation: async () => {},
|
||||
process: async () => {},
|
||||
updateActivity: async () => {},
|
||||
deleteActivity: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
|
||||
const createRecordedSendActivity = (
|
||||
@ -83,8 +86,8 @@ const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: async () => {},
|
||||
deleteActivity: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
});
|
||||
|
||||
describe("msteams messenger", () => {
|
||||
@ -192,13 +195,15 @@ describe("msteams messenger", () => {
|
||||
const seen: { reference?: unknown; texts: string[] } = { texts: [] };
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
...createNoopAdapter(),
|
||||
continueConversation: async (_appId, reference, logic) => {
|
||||
seen.reference = reference;
|
||||
await logic({
|
||||
sendActivity: createRecordedSendActivity(seen.texts),
|
||||
});
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
@ -366,10 +371,12 @@ describe("msteams messenger", () => {
|
||||
const attempts: string[] = [];
|
||||
|
||||
const adapter: MSTeamsAdapter = {
|
||||
...createNoopAdapter(),
|
||||
continueConversation: async (_appId, _reference, logic) => {
|
||||
await logic({ sendActivity: createRecordedSendActivity(attempts, 503) });
|
||||
},
|
||||
process: async () => {},
|
||||
updateActivity: noopUpdateActivity,
|
||||
deleteActivity: noopDeleteActivity,
|
||||
};
|
||||
|
||||
const ids = await sendMSTeamsMessages({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Nextcloud Talk extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/nextcloud-talk.js";
|
||||
export * from "openclaw/plugin-sdk/nextcloud-talk";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Nostr extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/nostr.js";
|
||||
export * from "openclaw/plugin-sdk/nostr";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Signal extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../../src/plugin-sdk/signal.js";
|
||||
export * from "openclaw/plugin-sdk/signal";
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { withTrustedWebToolsEndpoint } from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
normalizeCacheKey,
|
||||
postTrustedWebToolsJson,
|
||||
readCache,
|
||||
readResponseText,
|
||||
resolveCacheTtlMs,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
@ -26,7 +25,6 @@ const EXTRACT_CACHE = new Map<
|
||||
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
|
||||
>();
|
||||
const DEFAULT_SEARCH_COUNT = 5;
|
||||
const DEFAULT_ERROR_MAX_BYTES = 64_000;
|
||||
|
||||
export type TavilySearchParams = {
|
||||
cfg?: OpenClawConfig;
|
||||
@ -67,41 +65,6 @@ function resolveEndpoint(baseUrl: string, pathname: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function postTavilyJson(params: {
|
||||
baseUrl: string;
|
||||
pathname: string;
|
||||
apiKey: string;
|
||||
body: Record<string, unknown>;
|
||||
timeoutSeconds: number;
|
||||
errorLabel: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const endpoint = resolveEndpoint(params.baseUrl, params.pathname);
|
||||
return await withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES });
|
||||
throw new Error(
|
||||
`${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runTavilySearch(
|
||||
params: TavilySearchParams,
|
||||
): Promise<Record<string, unknown>> {
|
||||
@ -149,14 +112,16 @@ export async function runTavilySearch(
|
||||
if (params.excludeDomains?.length) body.exclude_domains = params.excludeDomains;
|
||||
|
||||
const start = Date.now();
|
||||
const payload = await postTavilyJson({
|
||||
baseUrl,
|
||||
pathname: "/search",
|
||||
apiKey,
|
||||
body,
|
||||
timeoutSeconds,
|
||||
errorLabel: "Tavily Search",
|
||||
});
|
||||
const payload = await postTrustedWebToolsJson(
|
||||
{
|
||||
url: resolveEndpoint(baseUrl, "/search"),
|
||||
timeoutSeconds,
|
||||
apiKey,
|
||||
body,
|
||||
errorLabel: "Tavily Search",
|
||||
},
|
||||
async (response) => (await response.json()) as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const rawResults = Array.isArray(payload.results) ? payload.results : [];
|
||||
const results = rawResults.map((r: Record<string, unknown>) => ({
|
||||
@ -228,14 +193,16 @@ export async function runTavilyExtract(
|
||||
if (params.includeImages) body.include_images = true;
|
||||
|
||||
const start = Date.now();
|
||||
const payload = await postTavilyJson({
|
||||
baseUrl,
|
||||
pathname: "/extract",
|
||||
apiKey,
|
||||
body,
|
||||
timeoutSeconds,
|
||||
errorLabel: "Tavily Extract",
|
||||
});
|
||||
const payload = await postTrustedWebToolsJson(
|
||||
{
|
||||
url: resolveEndpoint(baseUrl, "/extract"),
|
||||
timeoutSeconds,
|
||||
apiKey,
|
||||
body,
|
||||
errorLabel: "Tavily Extract",
|
||||
},
|
||||
async (response) => (await response.json()) as Record<string, unknown>,
|
||||
);
|
||||
|
||||
const rawResults = Array.isArray(payload.results) ? payload.results : [];
|
||||
const results = rawResults.map((r: Record<string, unknown>) => ({
|
||||
@ -282,5 +249,5 @@ export async function runTavilyExtract(
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
postTavilyJson,
|
||||
resolveEndpoint,
|
||||
};
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
const createTelegramDraftStream = vi.hoisted(() => vi.fn());
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => vi.fn());
|
||||
const deliverReplies = vi.hoisted(() => vi.fn());
|
||||
const emitInternalMessageSentHook = vi.hoisted(() => vi.fn());
|
||||
const createForumTopicTelegram = vi.hoisted(() => vi.fn());
|
||||
const deleteMessageTelegram = vi.hoisted(() => vi.fn());
|
||||
const editForumTopicTelegram = vi.hoisted(() => vi.fn());
|
||||
@ -46,6 +47,7 @@ vi.mock("./draft-stream.js", () => ({
|
||||
|
||||
vi.mock("./bot/delivery.js", () => ({
|
||||
deliverReplies,
|
||||
emitInternalMessageSentHook,
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
@ -103,6 +105,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
createTelegramDraftStream.mockClear();
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
deliverReplies.mockClear();
|
||||
emitInternalMessageSentHook.mockClear();
|
||||
createForumTopicTelegram.mockClear();
|
||||
deleteMessageTelegram.mockClear();
|
||||
editForumTopicTelegram.mockClear();
|
||||
@ -521,6 +524,38 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(draftStream.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits only the internal message:sent hook when a final answer stays in preview", async () => {
|
||||
const draftStream = createDraftStream(999);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "Primary result" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext({
|
||||
ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
expect(editMessageTelegram).toHaveBeenCalledWith(
|
||||
123,
|
||||
999,
|
||||
"Primary result",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(emitInternalMessageSentHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKeyForInternalHooks: "s1",
|
||||
chatId: "123",
|
||||
content: "Primary result",
|
||||
success: true,
|
||||
messageId: 999,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps streamed preview visible when final text regresses after a tool warning", async () => {
|
||||
const draftStream = createDraftStream(999);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
|
||||
@ -30,7 +30,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js";
|
||||
import type { TelegramMessageContext } from "./bot-message-context.js";
|
||||
import type { TelegramBotOptions } from "./bot.js";
|
||||
import { deliverReplies } from "./bot/delivery.js";
|
||||
import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js";
|
||||
import type { TelegramStreamMode } from "./bot/types.js";
|
||||
import type { TelegramInlineButtons } from "./button-types.js";
|
||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||
@ -41,6 +41,7 @@ import {
|
||||
createLaneDeliveryStateTracker,
|
||||
createLaneTextDeliverer,
|
||||
type DraftLaneState,
|
||||
type LaneDeliveryResult,
|
||||
type LaneName,
|
||||
type LanePreviewLifecycle,
|
||||
} from "./lane-delivery.js";
|
||||
@ -480,6 +481,21 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
return result.delivered;
|
||||
};
|
||||
const emitPreviewFinalizedHook = (result: LaneDeliveryResult) => {
|
||||
if (result.kind !== "preview-finalized") {
|
||||
return;
|
||||
}
|
||||
emitInternalMessageSentHook({
|
||||
sessionKeyForInternalHooks: deliveryBaseOptions.sessionKeyForInternalHooks,
|
||||
chatId: deliveryBaseOptions.chatId,
|
||||
accountId: deliveryBaseOptions.accountId,
|
||||
content: result.delivery.content,
|
||||
success: true,
|
||||
messageId: result.delivery.messageId,
|
||||
isGroup: deliveryBaseOptions.mirrorIsGroup,
|
||||
groupId: deliveryBaseOptions.mirrorGroupId,
|
||||
});
|
||||
};
|
||||
const deliverLaneText = createLaneTextDeliverer({
|
||||
lanes,
|
||||
archivedAnswerPreviews,
|
||||
@ -612,8 +628,11 @@ export const dispatchTelegramMessage = async ({
|
||||
previewButtons,
|
||||
allowPreviewUpdateForNonFinal: segment.lane === "reasoning",
|
||||
});
|
||||
if (info.kind === "final") {
|
||||
emitPreviewFinalizedHook(result);
|
||||
}
|
||||
if (segment.lane === "reasoning") {
|
||||
if (result !== "skipped") {
|
||||
if (result.kind !== "skipped") {
|
||||
reasoningStepState.noteReasoningDelivered();
|
||||
await flushBufferedFinalAnswer();
|
||||
}
|
||||
|
||||
@ -491,9 +491,7 @@ async function maybePinFirstDeliveredMessage(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function emitMessageSentHooks(params: {
|
||||
hookRunner: ReturnType<typeof getGlobalHookRunner>;
|
||||
enabled: boolean;
|
||||
type EmitMessageSentHookParams = {
|
||||
sessionKeyForInternalHooks?: string;
|
||||
chatId: string;
|
||||
accountId?: string;
|
||||
@ -503,11 +501,10 @@ function emitMessageSentHooks(params: {
|
||||
messageId?: number;
|
||||
isGroup?: boolean;
|
||||
groupId?: string;
|
||||
}): void {
|
||||
if (!params.enabled && !params.sessionKeyForInternalHooks) {
|
||||
return;
|
||||
}
|
||||
const canonical = buildCanonicalSentMessageHookContext({
|
||||
};
|
||||
|
||||
function buildTelegramSentHookContext(params: EmitMessageSentHookParams) {
|
||||
return buildCanonicalSentMessageHookContext({
|
||||
to: params.chatId,
|
||||
content: params.content,
|
||||
success: params.success,
|
||||
@ -519,20 +516,13 @@ function emitMessageSentHooks(params: {
|
||||
isGroup: params.isGroup,
|
||||
groupId: params.groupId,
|
||||
});
|
||||
if (params.enabled) {
|
||||
fireAndForgetHook(
|
||||
Promise.resolve(
|
||||
params.hookRunner!.runMessageSent(
|
||||
toPluginMessageSentEvent(canonical),
|
||||
toPluginMessageContext(canonical),
|
||||
),
|
||||
),
|
||||
"telegram: message_sent plugin hook failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitInternalMessageSentHook(params: EmitMessageSentHookParams): void {
|
||||
if (!params.sessionKeyForInternalHooks) {
|
||||
return;
|
||||
}
|
||||
const canonical = buildTelegramSentHookContext(params);
|
||||
fireAndForgetHook(
|
||||
triggerInternalHook(
|
||||
createInternalHookEvent(
|
||||
@ -546,6 +536,30 @@ function emitMessageSentHooks(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function emitMessageSentHooks(
|
||||
params: EmitMessageSentHookParams & {
|
||||
hookRunner: ReturnType<typeof getGlobalHookRunner>;
|
||||
enabled: boolean;
|
||||
},
|
||||
): void {
|
||||
if (!params.enabled && !params.sessionKeyForInternalHooks) {
|
||||
return;
|
||||
}
|
||||
const canonical = buildTelegramSentHookContext(params);
|
||||
if (params.enabled) {
|
||||
fireAndForgetHook(
|
||||
Promise.resolve(
|
||||
params.hookRunner!.runMessageSent(
|
||||
toPluginMessageSentEvent(canonical),
|
||||
toPluginMessageContext(canonical),
|
||||
),
|
||||
),
|
||||
"telegram: message_sent plugin hook failed",
|
||||
);
|
||||
}
|
||||
emitInternalMessageSentHook(params);
|
||||
}
|
||||
|
||||
export async function deliverReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
chatId: string;
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export { deliverReplies } from "./delivery.replies.js";
|
||||
export { deliverReplies, emitInternalMessageSentHook } from "./delivery.replies.js";
|
||||
export { resolveMedia } from "./delivery.resolve-media.js";
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
chunkMarkdownIR,
|
||||
FILE_REF_EXTENSIONS_WITH_TLD,
|
||||
isAutoLinkedFileRef,
|
||||
markdownToIR,
|
||||
type MarkdownLinkSpan,
|
||||
type MarkdownIR,
|
||||
@ -31,44 +33,6 @@ function escapeHtmlAttr(text: string): string {
|
||||
*
|
||||
* Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io)
|
||||
*/
|
||||
const FILE_EXTENSIONS_WITH_TLD = new Set([
|
||||
"md", // Markdown (Moldova) - very common in repos
|
||||
"go", // Go language - common in Go projects
|
||||
"py", // Python (Paraguay) - common in Python projects
|
||||
"pl", // Perl (Poland) - common in Perl projects
|
||||
"sh", // Shell (Saint Helena) - common for scripts
|
||||
"am", // Automake files (Armenia)
|
||||
"at", // Assembly (Austria)
|
||||
"be", // Backend files (Belgium)
|
||||
"cc", // C++ source (Cocos Islands)
|
||||
]);
|
||||
|
||||
/** Detects when markdown-it linkify auto-generated a link from a bare filename (e.g. README.md → http://README.md) */
|
||||
function isAutoLinkedFileRef(href: string, label: string): boolean {
|
||||
const stripped = href.replace(/^https?:\/\//i, "");
|
||||
if (stripped !== label) {
|
||||
return false;
|
||||
}
|
||||
const dotIndex = label.lastIndexOf(".");
|
||||
if (dotIndex < 1) {
|
||||
return false;
|
||||
}
|
||||
const ext = label.slice(dotIndex + 1).toLowerCase();
|
||||
if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
// Reject if any path segment before the filename contains a dot (looks like a domain)
|
||||
const segments = label.split("/");
|
||||
if (segments.length > 1) {
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
if (segments[i].includes(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildTelegramLink(link: MarkdownLinkSpan, text: string) {
|
||||
const href = link.href.trim();
|
||||
if (!href) {
|
||||
@ -139,7 +103,7 @@ function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
||||
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
||||
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
|
||||
const FILE_REFERENCE_PATTERN = new RegExp(
|
||||
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
|
||||
|
||||
@ -57,11 +57,14 @@ export type ArchivedPreview = {
|
||||
export type LanePreviewLifecycle = "transient" | "complete";
|
||||
|
||||
export type LaneDeliveryResult =
|
||||
| "preview-finalized"
|
||||
| "preview-retained"
|
||||
| "preview-updated"
|
||||
| "sent"
|
||||
| "skipped";
|
||||
| {
|
||||
kind: "preview-finalized";
|
||||
delivery: {
|
||||
content: string;
|
||||
messageId?: number;
|
||||
};
|
||||
}
|
||||
| { kind: "preview-retained" | "preview-updated" | "sent" | "skipped" };
|
||||
|
||||
type CreateLaneTextDelivererParams = {
|
||||
lanes: Record<LaneName, DraftLaneState>;
|
||||
@ -107,7 +110,7 @@ type TryUpdatePreviewParams = {
|
||||
previewTextSnapshot?: string;
|
||||
};
|
||||
|
||||
type PreviewEditResult = "edited" | "retained" | "fallback";
|
||||
type PreviewEditResult = "edited" | "retained" | "regressive-skipped" | "fallback";
|
||||
|
||||
type ConsumeArchivedAnswerPreviewParams = {
|
||||
lane: DraftLaneState;
|
||||
@ -133,6 +136,16 @@ type PreviewTargetResolution = {
|
||||
stopCreatesFirstPreview: boolean;
|
||||
};
|
||||
|
||||
function result(
|
||||
kind: LaneDeliveryResult["kind"],
|
||||
delivery?: Extract<LaneDeliveryResult, { kind: "preview-finalized" }>["delivery"],
|
||||
): LaneDeliveryResult {
|
||||
if (kind === "preview-finalized") {
|
||||
return { kind, delivery: delivery! };
|
||||
}
|
||||
return { kind };
|
||||
}
|
||||
|
||||
function shouldSkipRegressivePreviewUpdate(args: {
|
||||
currentPreviewText: string | undefined;
|
||||
text: string;
|
||||
@ -189,10 +202,10 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
lane: DraftLaneState;
|
||||
laneName: LaneName;
|
||||
text: string;
|
||||
}): Promise<boolean> => {
|
||||
}): Promise<number | undefined> => {
|
||||
const stream = args.lane.stream;
|
||||
if (!stream || !isDraftPreviewLane(args.lane)) {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
// Draft previews have no message_id to edit; materialize the final text
|
||||
// into a real message and treat that as the finalized delivery.
|
||||
@ -202,11 +215,11 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
params.log(
|
||||
`telegram: ${args.laneName} draft preview materialize produced no message id; falling back to standard send`,
|
||||
);
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
args.lane.lastPartialText = args.text;
|
||||
params.markDelivered();
|
||||
return true;
|
||||
return materializedMessageId;
|
||||
};
|
||||
|
||||
const tryEditPreviewMessage = async (args: {
|
||||
@ -338,7 +351,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
});
|
||||
if (shouldSkipRegressive) {
|
||||
params.markDelivered();
|
||||
return "edited";
|
||||
return "regressive-skipped";
|
||||
}
|
||||
return editPreview(
|
||||
previewMessageId,
|
||||
@ -427,11 +440,20 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
previewTextSnapshot: archivedPreview.textSnapshot,
|
||||
});
|
||||
if (finalized === "edited") {
|
||||
return "preview-finalized";
|
||||
return result("preview-finalized", {
|
||||
content: text,
|
||||
messageId: archivedPreview.messageId,
|
||||
});
|
||||
}
|
||||
if (finalized === "regressive-skipped") {
|
||||
return result("preview-finalized", {
|
||||
content: archivedPreview.textSnapshot,
|
||||
messageId: archivedPreview.messageId,
|
||||
});
|
||||
}
|
||||
if (finalized === "retained") {
|
||||
params.retainPreviewOnCleanupByLane.answer = true;
|
||||
return "preview-retained";
|
||||
return result("preview-retained");
|
||||
}
|
||||
}
|
||||
// Send the replacement message first, then clean up the old preview.
|
||||
@ -448,7 +470,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
);
|
||||
}
|
||||
}
|
||||
return delivered ? "sent" : "skipped";
|
||||
return delivered ? result("sent") : result("skipped");
|
||||
};
|
||||
|
||||
return async ({
|
||||
@ -499,16 +521,20 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
}
|
||||
}
|
||||
if (canMaterializeDraftFinal(lane, previewButtons)) {
|
||||
const materialized = await tryMaterializeDraftPreviewForFinal({
|
||||
const materializedMessageId = await tryMaterializeDraftPreviewForFinal({
|
||||
lane,
|
||||
laneName,
|
||||
text,
|
||||
});
|
||||
if (materialized) {
|
||||
if (typeof materializedMessageId === "number") {
|
||||
markActivePreviewComplete(laneName);
|
||||
return "preview-finalized";
|
||||
return result("preview-finalized", {
|
||||
content: text,
|
||||
messageId: materializedMessageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const previewMessageId = lane.stream?.messageId();
|
||||
const finalized = await tryUpdatePreviewForLane({
|
||||
lane,
|
||||
laneName,
|
||||
@ -520,11 +546,21 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
});
|
||||
if (finalized === "edited") {
|
||||
markActivePreviewComplete(laneName);
|
||||
return "preview-finalized";
|
||||
return result("preview-finalized", {
|
||||
content: text,
|
||||
messageId: previewMessageId ?? lane.stream?.messageId(),
|
||||
});
|
||||
}
|
||||
if (finalized === "regressive-skipped") {
|
||||
markActivePreviewComplete(laneName);
|
||||
return result("preview-finalized", {
|
||||
content: lane.lastPartialText,
|
||||
messageId: previewMessageId ?? lane.stream?.messageId(),
|
||||
});
|
||||
}
|
||||
if (finalized === "retained") {
|
||||
markActivePreviewComplete(laneName);
|
||||
return "preview-retained";
|
||||
return result("preview-retained");
|
||||
}
|
||||
} else if (!hasMedia && !payload.isError && text.length > params.draftMaxChars) {
|
||||
params.log(
|
||||
@ -533,7 +569,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
}
|
||||
await params.stopDraftLane(lane);
|
||||
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
|
||||
return delivered ? "sent" : "skipped";
|
||||
return delivered ? result("sent") : result("skipped");
|
||||
}
|
||||
|
||||
if (allowPreviewUpdateForNonFinal && canEditViaPreview) {
|
||||
@ -549,11 +585,11 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
`telegram: ${laneName} draft preview update not emitted; falling back to standard send`,
|
||||
);
|
||||
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
|
||||
return delivered ? "sent" : "skipped";
|
||||
return delivered ? result("sent") : result("skipped");
|
||||
}
|
||||
lane.lastPartialText = text;
|
||||
params.markDelivered();
|
||||
return "preview-updated";
|
||||
return result("preview-updated");
|
||||
}
|
||||
const updated = await tryUpdatePreviewForLane({
|
||||
lane,
|
||||
@ -565,12 +601,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
skipRegressive: "always",
|
||||
context: "update",
|
||||
});
|
||||
if (updated === "edited") {
|
||||
return "preview-updated";
|
||||
if (updated === "edited" || updated === "regressive-skipped") {
|
||||
return result("preview-updated");
|
||||
}
|
||||
}
|
||||
|
||||
const delivered = await params.sendPayload(params.applyTextToPayload(payload, text));
|
||||
return delivered ? "sent" : "skipped";
|
||||
return delivered ? result("sent") : result("skipped");
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
||||
import { createTestDraftStream } from "./draft-stream.test-helpers.js";
|
||||
import { createLaneTextDeliverer, type DraftLaneState, type LaneName } from "./lane-delivery.js";
|
||||
import {
|
||||
createLaneTextDeliverer,
|
||||
type DraftLaneState,
|
||||
type LaneDeliveryResult,
|
||||
type LaneName,
|
||||
} from "./lane-delivery.js";
|
||||
|
||||
const HELLO_FINAL = "Hello final";
|
||||
|
||||
@ -101,7 +106,7 @@ async function expectFinalPreviewRetained(params: {
|
||||
expectedLogSnippet?: string;
|
||||
}) {
|
||||
const result = await deliverFinalAnswer(params.harness, params.text ?? HELLO_FINAL);
|
||||
expect(result).toBe("preview-retained");
|
||||
expect(result.kind).toBe("preview-retained");
|
||||
expect(params.harness.sendPayload).not.toHaveBeenCalled();
|
||||
if (params.expectedLogSnippet) {
|
||||
expect(params.harness.log).toHaveBeenCalledWith(
|
||||
@ -124,7 +129,7 @@ async function expectFinalEditFallbackToSend(params: {
|
||||
expectedLogSnippet: string;
|
||||
}) {
|
||||
const result = await deliverFinalAnswer(params.harness, params.text);
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(params.harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
expect(params.harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: params.text }),
|
||||
@ -134,13 +139,23 @@ async function expectFinalEditFallbackToSend(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function expectPreviewFinalized(
|
||||
result: LaneDeliveryResult,
|
||||
): Extract<LaneDeliveryResult, { kind: "preview-finalized" }>["delivery"] {
|
||||
expect(result.kind).toBe("preview-finalized");
|
||||
if (result.kind !== "preview-finalized") {
|
||||
throw new Error(`expected preview-finalized, got ${result.kind}`);
|
||||
}
|
||||
return result.delivery;
|
||||
}
|
||||
|
||||
describe("createLaneTextDeliverer", () => {
|
||||
it("finalizes text-only replies by editing an existing preview message", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result).toBe("preview-finalized");
|
||||
expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 });
|
||||
expect(harness.editPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
laneName: "answer",
|
||||
@ -164,7 +179,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("preview-finalized");
|
||||
expect(expectPreviewFinalized(result)).toEqual({ content: "no problem", messageId: 777 });
|
||||
expect(harness.answer.stream?.update).toHaveBeenCalledWith("no problem");
|
||||
expect(harness.editPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -187,7 +202,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("preview-retained");
|
||||
expect(result.kind).toBe("preview-retained");
|
||||
expect(harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.log).toHaveBeenCalledWith(
|
||||
@ -205,7 +220,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result).toBe("preview-finalized");
|
||||
expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 });
|
||||
expect(harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
@ -244,7 +259,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: HELLO_FINAL }),
|
||||
);
|
||||
@ -273,7 +288,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "Short final" }),
|
||||
@ -291,7 +306,10 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("preview-finalized");
|
||||
expect(expectPreviewFinalized(result)).toEqual({
|
||||
content: "Recovered final answer.",
|
||||
messageId: 999,
|
||||
});
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
@ -308,7 +326,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(expect.objectContaining({ text: longText }));
|
||||
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long"));
|
||||
@ -331,7 +349,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("preview-finalized");
|
||||
expect(expectPreviewFinalized(result)).toEqual({ content: "Hello final", messageId: 321 });
|
||||
expect(harness.flushDraftLane).toHaveBeenCalled();
|
||||
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
@ -360,7 +378,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("preview-finalized");
|
||||
expect(expectPreviewFinalized(result)).toEqual({ content: "Final answer", messageId: 654 });
|
||||
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
@ -377,7 +395,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: HELLO_FINAL }),
|
||||
@ -402,7 +420,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "Image incoming", mediaUrl: "file:///tmp/example.png" }),
|
||||
);
|
||||
@ -425,7 +443,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "Choose one" }),
|
||||
);
|
||||
@ -456,7 +474,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "Complete final answer" }),
|
||||
);
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555);
|
||||
});
|
||||
|
||||
@ -469,12 +487,30 @@ describe("createLaneTextDeliverer", () => {
|
||||
|
||||
expect(harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(result).toBe("preview-retained");
|
||||
expect(result.kind).toBe("preview-retained");
|
||||
expect(harness.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("edit target missing; keeping alternate preview without fallback"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the archived preview when the final text regresses", async () => {
|
||||
const harness = createHarness();
|
||||
harness.archivedAnswerPreviews.push({
|
||||
messageId: 5555,
|
||||
textSnapshot: "Recovered final answer.",
|
||||
deleteIfUnused: true,
|
||||
});
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "Recovered final answer");
|
||||
|
||||
expect(expectPreviewFinalized(result)).toEqual({
|
||||
content: "Recovered final answer.",
|
||||
messageId: 5555,
|
||||
});
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back on 4xx client rejection with error_code during final", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
const err = Object.assign(new Error("403: Forbidden"), { error_code: 403 });
|
||||
@ -505,7 +541,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: HELLO_FINAL }),
|
||||
);
|
||||
@ -546,7 +582,7 @@ describe("createLaneTextDeliverer", () => {
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result).toBe("sent");
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "Final with media", mediaUrl: "file:///tmp/example.png" }),
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { resolveThreadBindingEffectiveExpiresAt } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { formatThreadBindingDurationLabel } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import {
|
||||
registerSessionBindingAdapter,
|
||||
@ -115,32 +116,6 @@ function toTelegramTargetKind(raw: BindingTargetKind): TelegramBindingTargetKind
|
||||
return raw === "subagent" ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
function resolveEffectiveBindingExpiresAt(params: {
|
||||
record: TelegramThreadBindingRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
defaultMaxAgeMs: number;
|
||||
}): number | undefined {
|
||||
const idleTimeoutMs =
|
||||
typeof params.record.idleTimeoutMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
|
||||
: params.defaultIdleTimeoutMs;
|
||||
const maxAgeMs =
|
||||
typeof params.record.maxAgeMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.maxAgeMs))
|
||||
: params.defaultMaxAgeMs;
|
||||
|
||||
const inactivityExpiresAt =
|
||||
idleTimeoutMs > 0
|
||||
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
|
||||
: undefined;
|
||||
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
|
||||
|
||||
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
|
||||
return Math.min(inactivityExpiresAt, maxAgeExpiresAt);
|
||||
}
|
||||
return inactivityExpiresAt ?? maxAgeExpiresAt;
|
||||
}
|
||||
|
||||
function toSessionBindingRecord(
|
||||
record: TelegramThreadBindingRecord,
|
||||
defaults: { idleTimeoutMs: number; maxAgeMs: number },
|
||||
@ -159,7 +134,7 @@ function toSessionBindingRecord(
|
||||
},
|
||||
status: "active",
|
||||
boundAt: record.boundAt,
|
||||
expiresAt: resolveEffectiveBindingExpiresAt({
|
||||
expiresAt: resolveThreadBindingEffectiveExpiresAt({
|
||||
record,
|
||||
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
|
||||
defaultMaxAgeMs: defaults.maxAgeMs,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Tlon extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/tlon.js";
|
||||
export * from "openclaw/plugin-sdk/tlon";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Twitch extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/twitch.js";
|
||||
export * from "openclaw/plugin-sdk/twitch";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Voice Call extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/voice-call.js";
|
||||
export * from "openclaw/plugin-sdk/voice-call";
|
||||
|
||||
@ -5,12 +5,12 @@ import {
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
getScopedCredentialValue,
|
||||
MAX_SEARCH_COUNT,
|
||||
mergeScopedSearchConfig,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
mergeScopedSearchConfig,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
@ -20,151 +20,24 @@ import {
|
||||
type SearchConfigRecord,
|
||||
type WebSearchProviderPlugin,
|
||||
type WebSearchProviderToolDefinition,
|
||||
withTrustedWebSearchEndpoint,
|
||||
wrapWebContent,
|
||||
writeCachedSearchPayload,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
buildXaiWebSearchPayload,
|
||||
extractXaiWebSearchContent,
|
||||
requestXaiWebSearch,
|
||||
resolveXaiInlineCitations,
|
||||
resolveXaiSearchConfig,
|
||||
resolveXaiWebSearchModel,
|
||||
} from "./web-search-shared.js";
|
||||
|
||||
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
|
||||
const DEFAULT_GROK_MODEL = "grok-4-1-fast";
|
||||
|
||||
type GrokConfig = {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
inlineCitations?: boolean;
|
||||
};
|
||||
|
||||
type GrokSearchResponse = {
|
||||
output?: Array<{
|
||||
type?: string;
|
||||
role?: string;
|
||||
text?: string;
|
||||
content?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
}>;
|
||||
}>;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
}>;
|
||||
}>;
|
||||
output_text?: string;
|
||||
citations?: string[];
|
||||
inline_citations?: Array<{
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function resolveGrokConfig(searchConfig?: SearchConfigRecord): GrokConfig {
|
||||
const grok = searchConfig?.grok;
|
||||
return grok && typeof grok === "object" && !Array.isArray(grok) ? (grok as GrokConfig) : {};
|
||||
}
|
||||
|
||||
function resolveGrokApiKey(grok?: GrokConfig): string | undefined {
|
||||
function resolveGrokApiKey(grok?: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
readConfiguredSecretString(grok?.apiKey, "tools.web.search.grok.apiKey") ??
|
||||
readProviderEnvValue(["XAI_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGrokModel(grok?: GrokConfig): string {
|
||||
const model = typeof grok?.model === "string" ? grok.model.trim() : "";
|
||||
return model || DEFAULT_GROK_MODEL;
|
||||
}
|
||||
|
||||
function resolveGrokInlineCitations(grok?: GrokConfig): boolean {
|
||||
return grok?.inlineCitations === true;
|
||||
}
|
||||
|
||||
function extractGrokContent(data: GrokSearchResponse): {
|
||||
text: string | undefined;
|
||||
annotationCitations: string[];
|
||||
} {
|
||||
for (const output of data.output ?? []) {
|
||||
if (output.type === "message") {
|
||||
for (const block of output.content ?? []) {
|
||||
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
|
||||
const urls = (block.annotations ?? [])
|
||||
.filter(
|
||||
(annotation) =>
|
||||
annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: block.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (output.type === "output_text" && typeof output.text === "string" && output.text) {
|
||||
const urls = (Array.isArray(output.annotations) ? output.annotations : [])
|
||||
.filter(
|
||||
(annotation) => annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: output.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: typeof data.output_text === "string" ? data.output_text : undefined,
|
||||
annotationCitations: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function runGrokSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
inlineCitations: boolean;
|
||||
}): Promise<{
|
||||
content: string;
|
||||
citations: string[];
|
||||
inlineCitations?: GrokSearchResponse["inline_citations"];
|
||||
}> {
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: XAI_API_ENDPOINT,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
input: [{ role: "user", content: params.query }],
|
||||
tools: [{ type: "web_search" }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`xAI API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as GrokSearchResponse;
|
||||
const { text, annotationCitations } = extractGrokContent(data);
|
||||
return {
|
||||
content: text ?? "No response",
|
||||
citations: (data.citations ?? []).length > 0 ? data.citations! : annotationCitations,
|
||||
inlineCitations: data.inline_citations,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createGrokSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
@ -197,7 +70,7 @@ function createGrokToolDefinition(
|
||||
return unsupportedResponse;
|
||||
}
|
||||
|
||||
const grokConfig = resolveGrokConfig(searchConfig);
|
||||
const grokConfig = resolveXaiSearchConfig(searchConfig);
|
||||
const apiKey = resolveGrokApiKey(grokConfig);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
@ -213,8 +86,8 @@ function createGrokToolDefinition(
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const model = resolveGrokModel(grokConfig);
|
||||
const inlineCitations = resolveGrokInlineCitations(grokConfig);
|
||||
const model = resolveXaiWebSearchModel(searchConfig);
|
||||
const inlineCitations = resolveXaiInlineCitations(searchConfig);
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"grok",
|
||||
query,
|
||||
@ -228,28 +101,22 @@ function createGrokToolDefinition(
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await runGrokSearch({
|
||||
const result = await requestXaiWebSearch({
|
||||
query,
|
||||
apiKey,
|
||||
model,
|
||||
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
||||
inlineCitations,
|
||||
});
|
||||
const payload = {
|
||||
const payload = buildXaiWebSearchPayload({
|
||||
query,
|
||||
provider: "grok",
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "grok",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
content: result.content,
|
||||
citations: result.citations,
|
||||
inlineCitations: result.inlineCitations,
|
||||
};
|
||||
});
|
||||
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
||||
return payload;
|
||||
},
|
||||
@ -289,7 +156,15 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin {
|
||||
|
||||
export const __testing = {
|
||||
resolveGrokApiKey,
|
||||
resolveGrokModel,
|
||||
resolveGrokInlineCitations,
|
||||
extractGrokContent,
|
||||
resolveGrokModel: (grok?: Record<string, unknown>) =>
|
||||
resolveXaiWebSearchModel(grok ? { grok } : undefined),
|
||||
resolveGrokInlineCitations: (grok?: Record<string, unknown>) =>
|
||||
resolveXaiInlineCitations(grok ? { grok } : undefined),
|
||||
extractGrokContent: extractXaiWebSearchContent,
|
||||
extractXaiWebSearchContent,
|
||||
resolveXaiInlineCitations,
|
||||
resolveXaiSearchConfig,
|
||||
resolveXaiWebSearchModel,
|
||||
requestXaiWebSearch,
|
||||
buildXaiWebSearchPayload,
|
||||
} as const;
|
||||
|
||||
171
extensions/xai/src/web-search-shared.ts
Normal file
171
extensions/xai/src/web-search-shared.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search";
|
||||
|
||||
export const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses";
|
||||
export const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast";
|
||||
|
||||
export type XaiWebSearchResponse = {
|
||||
output?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
content?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
}>;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
}>;
|
||||
output_text?: string;
|
||||
citations?: string[];
|
||||
inline_citations?: Array<{
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type XaiWebSearchConfig = Record<string, unknown> & {
|
||||
model?: unknown;
|
||||
inlineCitations?: unknown;
|
||||
};
|
||||
|
||||
export type XaiWebSearchResult = {
|
||||
content: string;
|
||||
citations: string[];
|
||||
inlineCitations?: XaiWebSearchResponse["inline_citations"];
|
||||
};
|
||||
|
||||
export function buildXaiWebSearchPayload(params: {
|
||||
query: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
tookMs: number;
|
||||
content: string;
|
||||
citations: string[];
|
||||
inlineCitations?: XaiWebSearchResponse["inline_citations"];
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
query: params.query,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
tookMs: params.tookMs,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: params.provider,
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(params.content, "web_search"),
|
||||
citations: params.citations,
|
||||
...(params.inlineCitations ? { inlineCitations: params.inlineCitations } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveXaiSearchConfig(searchConfig?: Record<string, unknown>): XaiWebSearchConfig {
|
||||
return (asRecord(searchConfig?.grok) as XaiWebSearchConfig | undefined) ?? {};
|
||||
}
|
||||
|
||||
export function resolveXaiWebSearchModel(searchConfig?: Record<string, unknown>): string {
|
||||
const config = resolveXaiSearchConfig(searchConfig);
|
||||
return typeof config.model === "string" && config.model.trim()
|
||||
? config.model.trim()
|
||||
: XAI_DEFAULT_WEB_SEARCH_MODEL;
|
||||
}
|
||||
|
||||
export function resolveXaiInlineCitations(searchConfig?: Record<string, unknown>): boolean {
|
||||
return resolveXaiSearchConfig(searchConfig).inlineCitations === true;
|
||||
}
|
||||
|
||||
export function extractXaiWebSearchContent(data: XaiWebSearchResponse): {
|
||||
text: string | undefined;
|
||||
annotationCitations: string[];
|
||||
} {
|
||||
for (const output of data.output ?? []) {
|
||||
if (output.type === "message") {
|
||||
for (const block of output.content ?? []) {
|
||||
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
|
||||
const urls = (block.annotations ?? [])
|
||||
.filter(
|
||||
(annotation) =>
|
||||
annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: block.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (output.type === "output_text" && typeof output.text === "string" && output.text) {
|
||||
const urls = (output.annotations ?? [])
|
||||
.filter(
|
||||
(annotation) => annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: output.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: typeof data.output_text === "string" ? data.output_text : undefined,
|
||||
annotationCitations: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestXaiWebSearch(params: {
|
||||
query: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
inlineCitations: boolean;
|
||||
}): Promise<XaiWebSearchResult> {
|
||||
return await postTrustedWebToolsJson(
|
||||
{
|
||||
url: XAI_WEB_SEARCH_ENDPOINT,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
apiKey: params.apiKey,
|
||||
body: {
|
||||
model: params.model,
|
||||
input: [{ role: "user", content: params.query }],
|
||||
tools: [{ type: "web_search" }],
|
||||
},
|
||||
errorLabel: "xAI",
|
||||
},
|
||||
async (response) => {
|
||||
const data = (await response.json()) as XaiWebSearchResponse;
|
||||
const { text, annotationCitations } = extractXaiWebSearchContent(data);
|
||||
const citations =
|
||||
Array.isArray(data.citations) && data.citations.length > 0
|
||||
? data.citations
|
||||
: annotationCitations;
|
||||
return {
|
||||
content: text ?? "No response",
|
||||
citations,
|
||||
inlineCitations:
|
||||
params.inlineCitations && Array.isArray(data.inline_citations)
|
||||
? data.inline_citations
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
buildXaiWebSearchPayload,
|
||||
extractXaiWebSearchContent,
|
||||
resolveXaiInlineCitations,
|
||||
resolveXaiSearchConfig,
|
||||
resolveXaiWebSearchModel,
|
||||
requestXaiWebSearch,
|
||||
XAI_DEFAULT_WEB_SEARCH_MODEL,
|
||||
} as const;
|
||||
@ -5,133 +5,29 @@ import {
|
||||
getScopedCredentialValue,
|
||||
normalizeCacheKey,
|
||||
readCache,
|
||||
readResponseText,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
resolveCacheTtlMs,
|
||||
resolveTimeoutSeconds,
|
||||
resolveWebSearchProviderCredential,
|
||||
setScopedCredentialValue,
|
||||
type WebSearchProviderPlugin,
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
buildXaiWebSearchPayload,
|
||||
extractXaiWebSearchContent,
|
||||
requestXaiWebSearch,
|
||||
resolveXaiInlineCitations,
|
||||
resolveXaiWebSearchModel,
|
||||
} from "./src/web-search-shared.js";
|
||||
|
||||
const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses";
|
||||
const XAI_DEFAULT_WEB_SEARCH_MODEL = "grok-4-1-fast";
|
||||
const XAI_WEB_SEARCH_CACHE = new Map<
|
||||
string,
|
||||
{ value: Record<string, unknown>; insertedAt: number; expiresAt: number }
|
||||
>();
|
||||
|
||||
type XaiWebSearchResponse = {
|
||||
output?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
content?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
}>;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
}>;
|
||||
output_text?: string;
|
||||
citations?: string[];
|
||||
inline_citations?: Array<{
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function extractXaiWebSearchContent(data: XaiWebSearchResponse): {
|
||||
text: string | undefined;
|
||||
annotationCitations: string[];
|
||||
} {
|
||||
for (const output of data.output ?? []) {
|
||||
if (output.type === "message") {
|
||||
for (const block of output.content ?? []) {
|
||||
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
|
||||
const urls = (block.annotations ?? [])
|
||||
.filter(
|
||||
(annotation) =>
|
||||
annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: block.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (output.type === "output_text" && typeof output.text === "string" && output.text) {
|
||||
const urls = (output.annotations ?? [])
|
||||
.filter(
|
||||
(annotation) => annotation.type === "url_citation" && typeof annotation.url === "string",
|
||||
)
|
||||
.map((annotation) => annotation.url as string);
|
||||
return { text: output.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: typeof data.output_text === "string" ? data.output_text : undefined,
|
||||
annotationCitations: [],
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveXaiWebSearchConfig(
|
||||
searchConfig?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return asRecord(searchConfig?.grok) ?? {};
|
||||
}
|
||||
|
||||
function resolveXaiWebSearchModel(searchConfig?: Record<string, unknown>): string {
|
||||
const config = resolveXaiWebSearchConfig(searchConfig);
|
||||
return typeof config.model === "string" && config.model.trim()
|
||||
? config.model.trim()
|
||||
: XAI_DEFAULT_WEB_SEARCH_MODEL;
|
||||
}
|
||||
|
||||
function resolveXaiInlineCitations(searchConfig?: Record<string, unknown>): boolean {
|
||||
return resolveXaiWebSearchConfig(searchConfig).inlineCitations === true;
|
||||
}
|
||||
|
||||
function readQuery(args: Record<string, unknown>): string {
|
||||
const value = typeof args.query === "string" ? args.query.trim() : "";
|
||||
if (!value) {
|
||||
throw new Error("query required");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readCount(args: Record<string, unknown>): number {
|
||||
const raw = args.count;
|
||||
const parsed =
|
||||
typeof raw === "number" && Number.isFinite(raw)
|
||||
? raw
|
||||
: typeof raw === "string" && raw.trim()
|
||||
? Number.parseFloat(raw)
|
||||
: 5;
|
||||
return Math.max(1, Math.min(10, Math.trunc(parsed)));
|
||||
}
|
||||
|
||||
async function throwXaiWebSearchApiError(res: Response): Promise<never> {
|
||||
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
|
||||
throw new Error(`xAI API error (${res.status}): ${detailResult.text || res.statusText}`);
|
||||
}
|
||||
|
||||
async function runXaiWebSearch(params: {
|
||||
function runXaiWebSearch(params: {
|
||||
query: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
@ -144,61 +40,31 @@ async function runXaiWebSearch(params: {
|
||||
);
|
||||
const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey);
|
||||
if (cached) {
|
||||
return { ...cached.value, cached: true };
|
||||
return Promise.resolve({ ...cached.value, cached: true });
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const payload = await withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: XAI_WEB_SEARCH_ENDPOINT,
|
||||
return (async () => {
|
||||
const startedAt = Date.now();
|
||||
const result = await requestXaiWebSearch({
|
||||
query: params.query,
|
||||
model: params.model,
|
||||
apiKey: params.apiKey,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
input: [{ role: "user", content: params.query }],
|
||||
tools: [{ type: "web_search" }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
return await throwXaiWebSearchApiError(response);
|
||||
}
|
||||
inlineCitations: params.inlineCitations,
|
||||
});
|
||||
const payload = buildXaiWebSearchPayload({
|
||||
query: params.query,
|
||||
provider: "grok",
|
||||
model: params.model,
|
||||
tookMs: Date.now() - startedAt,
|
||||
content: result.content,
|
||||
citations: result.citations,
|
||||
inlineCitations: result.inlineCitations,
|
||||
});
|
||||
|
||||
const data = (await response.json()) as XaiWebSearchResponse;
|
||||
const { text, annotationCitations } = extractXaiWebSearchContent(data);
|
||||
const citations =
|
||||
Array.isArray(data.citations) && data.citations.length > 0
|
||||
? data.citations
|
||||
: annotationCitations;
|
||||
|
||||
return {
|
||||
query: params.query,
|
||||
provider: "grok",
|
||||
model: params.model,
|
||||
tookMs: Date.now() - startedAt,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "grok",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(text ?? "No response", "web_search"),
|
||||
citations,
|
||||
...(params.inlineCitations && Array.isArray(data.inline_citations)
|
||||
? { inlineCitations: data.inline_citations }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
})();
|
||||
}
|
||||
|
||||
export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
@ -246,8 +112,9 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
};
|
||||
}
|
||||
|
||||
const query = readQuery(args);
|
||||
const count = readCount(args);
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
void readNumberParam(args, "count", { integer: true });
|
||||
|
||||
return await runXaiWebSearch({
|
||||
query,
|
||||
model: resolveXaiWebSearchModel(ctx.searchConfig),
|
||||
@ -268,7 +135,9 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin {
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
buildXaiWebSearchPayload,
|
||||
extractXaiWebSearchContent,
|
||||
resolveXaiWebSearchModel,
|
||||
resolveXaiInlineCitations,
|
||||
resolveXaiWebSearchModel,
|
||||
requestXaiWebSearch,
|
||||
};
|
||||
|
||||
@ -1 +1,5 @@
|
||||
export * from "../../src/plugin-sdk/zai.js";
|
||||
export {
|
||||
detectZaiEndpoint,
|
||||
type ZaiDetectedEndpoint,
|
||||
type ZaiEndpointId,
|
||||
} from "openclaw/plugin-sdk/provider-zai-endpoint";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Zalo extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/zalo.js";
|
||||
export * from "openclaw/plugin-sdk/zalo";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Zalo Personal extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/zalouser.js";
|
||||
export * from "openclaw/plugin-sdk/zalouser";
|
||||
|
||||
@ -39,7 +39,12 @@ import { probeZalouser } from "./probe.js";
|
||||
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||
import { resolveZalouserOutboundSessionRoute } from "./session-route.js";
|
||||
import {
|
||||
normalizeZalouserTarget,
|
||||
parseZalouserDirectoryGroupId,
|
||||
parseZalouserOutboundTarget,
|
||||
resolveZalouserOutboundSessionRoute,
|
||||
} from "./session-route.js";
|
||||
import { zalouserSetupAdapter } from "./setup-core.js";
|
||||
import { zalouserSetupWizard } from "./setup-surface.js";
|
||||
import { createZalouserPluginBase } from "./shared.js";
|
||||
@ -56,97 +61,6 @@ import {
|
||||
|
||||
const ZALOUSER_TEXT_CHUNK_LIMIT = 2000;
|
||||
|
||||
function stripZalouserTargetPrefix(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(zalouser|zlu):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizePrefixedTarget(raw: string): string | undefined {
|
||||
const trimmed = stripZalouserTargetPrefix(raw);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("group:")) {
|
||||
const id = trimmed.slice("group:".length).trim();
|
||||
return id ? `group:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("g:")) {
|
||||
const id = trimmed.slice("g:".length).trim();
|
||||
return id ? `group:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("dm:")) {
|
||||
const id = trimmed.slice("dm:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("u:")) {
|
||||
const id = trimmed.slice("u:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (/^g-\S+$/i.test(trimmed)) {
|
||||
return `group:${trimmed}`;
|
||||
}
|
||||
if (/^u-\S+$/i.test(trimmed)) {
|
||||
return `user:${trimmed}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseZalouserOutboundTarget(raw: string): {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
} {
|
||||
const normalized = normalizePrefixedTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser target is required");
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const threadId = normalized.slice("group:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return { threadId, isGroup: true };
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
const threadId = normalized.slice("user:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser user target is missing user id");
|
||||
}
|
||||
return { threadId, isGroup: false };
|
||||
}
|
||||
// Backward-compatible fallback for bare IDs.
|
||||
// Group sends should use explicit `group:<id>` targets.
|
||||
return { threadId: normalized, isGroup: false };
|
||||
}
|
||||
|
||||
function parseZalouserDirectoryGroupId(raw: string): string {
|
||||
const normalized = normalizePrefixedTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser group target is required");
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const groupId = normalized.slice("group:".length).trim();
|
||||
if (!groupId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return groupId;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveZalouserQrProfile(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
|
||||
@ -318,11 +232,11 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
},
|
||||
actions: zalouserMessageActions,
|
||||
messaging: {
|
||||
normalizeTarget: (raw) => normalizePrefixedTarget(raw),
|
||||
normalizeTarget: (raw) => normalizeZalouserTarget(raw),
|
||||
resolveOutboundSessionRoute: (params) => resolveZalouserOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const normalized = normalizePrefixedTarget(raw);
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -3,14 +3,14 @@ import {
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
|
||||
function stripZalouserTargetPrefix(raw: string): string {
|
||||
export function stripZalouserTargetPrefix(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(zalouser|zlu):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizePrefixedTarget(raw: string): string | undefined {
|
||||
export function normalizeZalouserTarget(raw: string): string | undefined {
|
||||
const trimmed = stripZalouserTargetPrefix(raw);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
@ -47,8 +47,55 @@ function normalizePrefixedTarget(raw: string): string | undefined {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseZalouserOutboundTarget(raw: string): {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
} {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser target is required");
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const threadId = normalized.slice("group:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return { threadId, isGroup: true };
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
const threadId = normalized.slice("user:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser user target is missing user id");
|
||||
}
|
||||
return { threadId, isGroup: false };
|
||||
}
|
||||
// Backward-compatible fallback for bare IDs.
|
||||
// Group sends should use explicit `group:<id>` targets.
|
||||
return { threadId: normalized, isGroup: false };
|
||||
}
|
||||
|
||||
export function parseZalouserDirectoryGroupId(raw: string): string {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser group target is required");
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("group:")) {
|
||||
const groupId = normalized.slice("group:".length).trim();
|
||||
if (!groupId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return groupId;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveZalouserOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||
const normalized = normalizePrefixedTarget(params.target);
|
||||
const normalized = normalizeZalouserTarget(params.target);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
76
package.json
76
package.json
@ -169,6 +169,10 @@
|
||||
"types": "./dist/plugin-sdk/process-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/process-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/windows-spawn": {
|
||||
"types": "./dist/plugin-sdk/windows-spawn.d.ts",
|
||||
"default": "./dist/plugin-sdk/windows-spawn.js"
|
||||
},
|
||||
"./plugin-sdk/acp-runtime": {
|
||||
"types": "./dist/plugin-sdk/acp-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/acp-runtime.js"
|
||||
@ -189,10 +193,50 @@
|
||||
"types": "./dist/plugin-sdk/discord-core.d.ts",
|
||||
"default": "./dist/plugin-sdk/discord-core.js"
|
||||
},
|
||||
"./plugin-sdk/feishu": {
|
||||
"types": "./dist/plugin-sdk/feishu.d.ts",
|
||||
"default": "./dist/plugin-sdk/feishu.js"
|
||||
},
|
||||
"./plugin-sdk/googlechat": {
|
||||
"types": "./dist/plugin-sdk/googlechat.d.ts",
|
||||
"default": "./dist/plugin-sdk/googlechat.js"
|
||||
},
|
||||
"./plugin-sdk/irc": {
|
||||
"types": "./dist/plugin-sdk/irc.d.ts",
|
||||
"default": "./dist/plugin-sdk/irc.js"
|
||||
},
|
||||
"./plugin-sdk/line": {
|
||||
"types": "./dist/plugin-sdk/line.d.ts",
|
||||
"default": "./dist/plugin-sdk/line.js"
|
||||
},
|
||||
"./plugin-sdk/line-core": {
|
||||
"types": "./dist/plugin-sdk/line-core.d.ts",
|
||||
"default": "./dist/plugin-sdk/line-core.js"
|
||||
},
|
||||
"./plugin-sdk/matrix": {
|
||||
"types": "./dist/plugin-sdk/matrix.d.ts",
|
||||
"default": "./dist/plugin-sdk/matrix.js"
|
||||
},
|
||||
"./plugin-sdk/mattermost": {
|
||||
"types": "./dist/plugin-sdk/mattermost.d.ts",
|
||||
"default": "./dist/plugin-sdk/mattermost.js"
|
||||
},
|
||||
"./plugin-sdk/msteams": {
|
||||
"types": "./dist/plugin-sdk/msteams.d.ts",
|
||||
"default": "./dist/plugin-sdk/msteams.js"
|
||||
},
|
||||
"./plugin-sdk/nextcloud-talk": {
|
||||
"types": "./dist/plugin-sdk/nextcloud-talk.d.ts",
|
||||
"default": "./dist/plugin-sdk/nextcloud-talk.js"
|
||||
},
|
||||
"./plugin-sdk/nostr": {
|
||||
"types": "./dist/plugin-sdk/nostr.d.ts",
|
||||
"default": "./dist/plugin-sdk/nostr.js"
|
||||
},
|
||||
"./plugin-sdk/signal": {
|
||||
"types": "./dist/plugin-sdk/signal.d.ts",
|
||||
"default": "./dist/plugin-sdk/signal.js"
|
||||
},
|
||||
"./plugin-sdk/slack": {
|
||||
"types": "./dist/plugin-sdk/slack.d.ts",
|
||||
"default": "./dist/plugin-sdk/slack.js"
|
||||
@ -201,6 +245,26 @@
|
||||
"types": "./dist/plugin-sdk/slack-core.d.ts",
|
||||
"default": "./dist/plugin-sdk/slack-core.js"
|
||||
},
|
||||
"./plugin-sdk/tlon": {
|
||||
"types": "./dist/plugin-sdk/tlon.d.ts",
|
||||
"default": "./dist/plugin-sdk/tlon.js"
|
||||
},
|
||||
"./plugin-sdk/twitch": {
|
||||
"types": "./dist/plugin-sdk/twitch.d.ts",
|
||||
"default": "./dist/plugin-sdk/twitch.js"
|
||||
},
|
||||
"./plugin-sdk/voice-call": {
|
||||
"types": "./dist/plugin-sdk/voice-call.d.ts",
|
||||
"default": "./dist/plugin-sdk/voice-call.js"
|
||||
},
|
||||
"./plugin-sdk/zalo": {
|
||||
"types": "./dist/plugin-sdk/zalo.d.ts",
|
||||
"default": "./dist/plugin-sdk/zalo.js"
|
||||
},
|
||||
"./plugin-sdk/zalouser": {
|
||||
"types": "./dist/plugin-sdk/zalouser.d.ts",
|
||||
"default": "./dist/plugin-sdk/zalouser.js"
|
||||
},
|
||||
"./plugin-sdk/imessage": {
|
||||
"types": "./dist/plugin-sdk/imessage.d.ts",
|
||||
"default": "./dist/plugin-sdk/imessage.js"
|
||||
@ -357,6 +421,14 @@
|
||||
"types": "./dist/plugin-sdk/provider-catalog.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-catalog.js"
|
||||
},
|
||||
"./plugin-sdk/provider-env-vars": {
|
||||
"types": "./dist/plugin-sdk/provider-env-vars.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-env-vars.js"
|
||||
},
|
||||
"./plugin-sdk/provider-google": {
|
||||
"types": "./dist/plugin-sdk/provider-google.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-google.js"
|
||||
},
|
||||
"./plugin-sdk/provider-models": {
|
||||
"types": "./dist/plugin-sdk/provider-models.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-models.js"
|
||||
@ -377,6 +449,10 @@
|
||||
"types": "./dist/plugin-sdk/provider-web-search.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-web-search.js"
|
||||
},
|
||||
"./plugin-sdk/provider-zai-endpoint": {
|
||||
"types": "./dist/plugin-sdk/provider-zai-endpoint.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-zai-endpoint.js"
|
||||
},
|
||||
"./plugin-sdk/image-generation": {
|
||||
"types": "./dist/plugin-sdk/image-generation.d.ts",
|
||||
"default": "./dist/plugin-sdk/image-generation.js"
|
||||
|
||||
@ -194,7 +194,10 @@ function scanWebSearchRegistrySmells(sourceFile, filePath) {
|
||||
|
||||
function shouldSkipFile(filePath) {
|
||||
const relativeFile = normalizePath(filePath);
|
||||
return relativeFile.startsWith("src/plugins/contracts/");
|
||||
return (
|
||||
relativeFile.startsWith("src/plugins/contracts/") ||
|
||||
/^src\/plugins\/runtime\/runtime-[^/]+-contract\.[cm]?[jt]s$/u.test(relativeFile)
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectPluginExtensionImportBoundaryInventory() {
|
||||
|
||||
@ -42,7 +42,7 @@ const exportedNames = exportMatch[1]
|
||||
|
||||
const exportSet = new Set(exportedNames);
|
||||
|
||||
const requiredRuntimeShimEntries = ["root-alias.cjs"];
|
||||
const requiredRuntimeShimEntries = ["compat.js", "root-alias.cjs"];
|
||||
|
||||
// Critical functions that channel extension plugins import from openclaw/plugin-sdk.
|
||||
// If any of these are missing, plugins will fail at runtime with:
|
||||
@ -65,6 +65,7 @@ const requiredExports = [
|
||||
"resolveChannelMediaMaxBytes",
|
||||
"warnMissingProviderGroupPolicyFallbackOnce",
|
||||
"emptyPluginConfigSchema",
|
||||
"onDiagnosticEvent",
|
||||
"normalizePluginHttpPath",
|
||||
"registerPluginHttpRoute",
|
||||
"DEFAULT_ACCOUNT_ID",
|
||||
|
||||
@ -32,14 +32,30 @@
|
||||
"cli-runtime",
|
||||
"hook-runtime",
|
||||
"process-runtime",
|
||||
"windows-spawn",
|
||||
"acp-runtime",
|
||||
"telegram",
|
||||
"telegram-core",
|
||||
"discord",
|
||||
"discord-core",
|
||||
"feishu",
|
||||
"googlechat",
|
||||
"irc",
|
||||
"line",
|
||||
"line-core",
|
||||
"matrix",
|
||||
"mattermost",
|
||||
"msteams",
|
||||
"nextcloud-talk",
|
||||
"nostr",
|
||||
"signal",
|
||||
"slack",
|
||||
"slack-core",
|
||||
"tlon",
|
||||
"twitch",
|
||||
"voice-call",
|
||||
"zalo",
|
||||
"zalouser",
|
||||
"imessage",
|
||||
"imessage-core",
|
||||
"whatsapp",
|
||||
@ -79,11 +95,14 @@
|
||||
"provider-auth-login",
|
||||
"plugin-entry",
|
||||
"provider-catalog",
|
||||
"provider-env-vars",
|
||||
"provider-google",
|
||||
"provider-models",
|
||||
"provider-onboard",
|
||||
"provider-stream",
|
||||
"provider-usage",
|
||||
"provider-web-search",
|
||||
"provider-zai-endpoint",
|
||||
"image-generation",
|
||||
"reply-history",
|
||||
"media-understanding",
|
||||
|
||||
@ -21,6 +21,7 @@ const requiredPathGroups = [
|
||||
["dist/index.js", "dist/index.mjs"],
|
||||
["dist/entry.js", "dist/entry.mjs"],
|
||||
...listPluginSdkDistArtifacts(),
|
||||
"dist/plugin-sdk/compat.js",
|
||||
"dist/plugin-sdk/root-alias.cjs",
|
||||
"dist/build-info.json",
|
||||
];
|
||||
@ -228,6 +229,7 @@ const requiredPluginSdkExports = [
|
||||
"resolveChannelMediaMaxBytes",
|
||||
"warnMissingProviderGroupPolicyFallbackOnce",
|
||||
"emptyPluginConfigSchema",
|
||||
"onDiagnosticEvent",
|
||||
"normalizePluginHttpPath",
|
||||
"registerPluginHttpRoute",
|
||||
"DEFAULT_ACCOUNT_ID",
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
resolveTestRunExitCode,
|
||||
} from "./test-parallel-utils.mjs";
|
||||
import {
|
||||
dedupeFilesPreserveOrder,
|
||||
loadUnitMemoryHotspotManifest,
|
||||
loadTestRunnerBehavior,
|
||||
loadUnitTimingManifest,
|
||||
@ -81,18 +82,18 @@ const testProfile =
|
||||
? rawTestProfile
|
||||
: "normal";
|
||||
const isMacMiniProfile = testProfile === "macmini";
|
||||
// vmForks is a big win for transform/import heavy suites. Node 24 is stable again
|
||||
// for the default unit-fast lane after moving the known flaky files to fork-only
|
||||
// isolation, but Node 25+ still falls back to process forks until re-validated.
|
||||
// Keep it opt-out via OPENCLAW_TEST_VM_FORKS=0, and let users force-enable with =1.
|
||||
// Vitest executes Node tests through Vite's SSR/module-runner pipeline, so the
|
||||
// shared unit lane still retains transformed ESM/module state even when the
|
||||
// tests themselves are not "server rendering" a website. vmForks can win in
|
||||
// ideal transform-heavy cases, but for this repo we measured higher aggregate
|
||||
// CPU load and fatal heap OOMs on memory-constrained dev machines and CI when
|
||||
// unit-fast stayed on vmForks. Keep forks as the default unless that evidence
|
||||
// is re-run and replaced:
|
||||
// PR: https://github.com/openclaw/openclaw/pull/51145
|
||||
// OOM evidence: https://github.com/openclaw/openclaw/pull/51145#issuecomment-4099663958
|
||||
// Preserve OPENCLAW_TEST_VM_FORKS=1 as the explicit override/debug escape hatch.
|
||||
const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor <= 24 : true;
|
||||
const useVmForks =
|
||||
process.env.OPENCLAW_TEST_VM_FORKS === "1" ||
|
||||
(process.env.OPENCLAW_TEST_VM_FORKS !== "0" &&
|
||||
!isWindows &&
|
||||
supportsVmForks &&
|
||||
!lowMemLocalHost &&
|
||||
(isCI || testProfile !== "low"));
|
||||
const useVmForks = process.env.OPENCLAW_TEST_VM_FORKS === "1" && supportsVmForks;
|
||||
const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1";
|
||||
const includeGatewaySuite = process.env.OPENCLAW_TEST_INCLUDE_GATEWAY === "1";
|
||||
const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === "1";
|
||||
@ -287,8 +288,6 @@ const parseEnvNumber = (name, fallback) => {
|
||||
const parsed = Number.parseInt(process.env[name] ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
};
|
||||
const shardedCi = isCI && shardCount > 1;
|
||||
const shardedLinuxCi = shardedCi && !isWindows && !isMacOS;
|
||||
const allKnownUnitFiles = allKnownTestFiles.filter((file) => {
|
||||
return isUnitConfigTestFile(file);
|
||||
});
|
||||
@ -347,18 +346,46 @@ const { memoryHeavyFiles: memoryHeavyUnitFiles, timedHeavyFiles: timedHeavyUnitF
|
||||
memoryHeavyFiles: [],
|
||||
timedHeavyFiles: [],
|
||||
};
|
||||
const unitSingletonBatchFiles = dedupeFilesPreserveOrder(
|
||||
unitSingletonIsolatedFiles,
|
||||
new Set(unitBehaviorIsolatedFiles),
|
||||
);
|
||||
const unitMemorySingletonFiles = dedupeFilesPreserveOrder(
|
||||
memoryHeavyUnitFiles,
|
||||
new Set([...unitBehaviorOverrideSet, ...unitSingletonBatchFiles]),
|
||||
);
|
||||
const unitSchedulingOverrideSet = new Set([...unitBehaviorOverrideSet, ...memoryHeavyUnitFiles]);
|
||||
const unitFastExcludedFiles = [
|
||||
...new Set([...unitSchedulingOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]),
|
||||
];
|
||||
const unitAutoSingletonFiles = [
|
||||
...new Set([...unitSingletonIsolatedFiles, ...memoryHeavyUnitFiles]),
|
||||
];
|
||||
// Sharded Linux CI still sees the broadest heap retention in the shared unit-fast lane. Prefer
|
||||
// process forks there so the workers release memory more aggressively between files.
|
||||
const unitFastPool = useVmForks && !shardedLinuxCi ? "vmForks" : "forks";
|
||||
const defaultSingletonBatchLaneCount =
|
||||
testProfile === "serial"
|
||||
? 0
|
||||
: unitSingletonBatchFiles.length === 0
|
||||
? 0
|
||||
: isCI
|
||||
? Math.ceil(unitSingletonBatchFiles.length / 6)
|
||||
: highMemLocalHost
|
||||
? Math.ceil(unitSingletonBatchFiles.length / 8)
|
||||
: lowMemLocalHost
|
||||
? Math.ceil(unitSingletonBatchFiles.length / 12)
|
||||
: Math.ceil(unitSingletonBatchFiles.length / 10);
|
||||
const singletonBatchLaneCount =
|
||||
unitSingletonBatchFiles.length === 0
|
||||
? 0
|
||||
: Math.min(
|
||||
unitSingletonBatchFiles.length,
|
||||
Math.max(
|
||||
1,
|
||||
parseEnvNumber("OPENCLAW_TEST_SINGLETON_ISOLATED_LANES", defaultSingletonBatchLaneCount),
|
||||
),
|
||||
);
|
||||
const estimateUnitDurationMs = (file) =>
|
||||
unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs;
|
||||
const unitSingletonBuckets =
|
||||
singletonBatchLaneCount > 0
|
||||
? packFilesByDuration(unitSingletonBatchFiles, singletonBatchLaneCount, estimateUnitDurationMs)
|
||||
: [];
|
||||
const unitFastExcludedFileSet = new Set(unitFastExcludedFiles);
|
||||
const unitFastCandidateFiles = allKnownUnitFiles.filter(
|
||||
(file) => !unitFastExcludedFileSet.has(file),
|
||||
@ -392,7 +419,7 @@ const unitFastEntries = unitFastBuckets
|
||||
"run",
|
||||
"--config",
|
||||
"vitest.unit.config.ts",
|
||||
`--pool=${unitFastPool}`,
|
||||
`--pool=${useVmForks ? "vmForks" : "forks"}`,
|
||||
...(disableIsolation ? ["--isolate=false"] : []),
|
||||
],
|
||||
}));
|
||||
@ -405,6 +432,11 @@ const unitHeavyEntries = heavyUnitBuckets.map((files, index) => ({
|
||||
name: `unit-heavy-${String(index + 1)}`,
|
||||
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files],
|
||||
}));
|
||||
const unitSingletonEntries = unitSingletonBuckets.map((files, index) => ({
|
||||
name:
|
||||
unitSingletonBuckets.length === 1 ? "unit-singleton" : `unit-singleton-${String(index + 1)}`,
|
||||
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files],
|
||||
}));
|
||||
const baseRuns = [
|
||||
...(shouldSplitUnitRuns
|
||||
? [
|
||||
@ -425,7 +457,8 @@ const baseRuns = [
|
||||
]
|
||||
: []),
|
||||
...unitHeavyEntries,
|
||||
...unitAutoSingletonFiles.map((file) => ({
|
||||
...unitSingletonEntries,
|
||||
...unitMemorySingletonFiles.map((file) => ({
|
||||
name: `${path.basename(file, ".test.ts")}-isolated`,
|
||||
args: [
|
||||
"vitest",
|
||||
@ -754,26 +787,20 @@ const defaultWorkerBudget =
|
||||
extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))),
|
||||
gateway: 1,
|
||||
};
|
||||
const shardedCiWorkerBudget =
|
||||
shardedCi && !isMacOS
|
||||
? {
|
||||
// Sharded Linux/Windows CI runs already divide the file set, so a smaller worker
|
||||
// fan-out keeps vmFork/fork heaps under control without penalizing local runs.
|
||||
unit: 2,
|
||||
unitIsolated: 1,
|
||||
extensions: 2,
|
||||
gateway: 1,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
|
||||
// On sharded Linux/Windows CI, cap worker fan-out to avoid unit-fast heap blowups while still
|
||||
// keeping enough concurrency to finish quickly. Non-sharded CI keeps Vitest defaults.
|
||||
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
||||
const maxWorkersForRun = (name) => {
|
||||
if (resolvedOverride) {
|
||||
return resolvedOverride;
|
||||
}
|
||||
if (shardedLinuxCi && name.startsWith("unit-fast")) {
|
||||
if (name === "unit-singleton" || name.startsWith("unit-singleton-")) {
|
||||
return 1;
|
||||
}
|
||||
if (isCI && !isMacOS) {
|
||||
return null;
|
||||
}
|
||||
if (isCI && isMacOS) {
|
||||
return 1;
|
||||
}
|
||||
if (name.endsWith("-threads") || name.endsWith("-vmforks")) {
|
||||
@ -782,24 +809,6 @@ const maxWorkersForRun = (name) => {
|
||||
if (name.endsWith("-isolated") && name !== "unit-isolated") {
|
||||
return 1;
|
||||
}
|
||||
if (isCI && !isMacOS) {
|
||||
if (shardedCiWorkerBudget) {
|
||||
if (name === "unit-isolated" || name.startsWith("unit-heavy-")) {
|
||||
return shardedCiWorkerBudget.unitIsolated;
|
||||
}
|
||||
if (name === "extensions") {
|
||||
return shardedCiWorkerBudget.extensions;
|
||||
}
|
||||
if (name === "gateway") {
|
||||
return shardedCiWorkerBudget.gateway;
|
||||
}
|
||||
return shardedCiWorkerBudget.unit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isCI && isMacOS) {
|
||||
return 1;
|
||||
}
|
||||
if (name === "unit-isolated" || name.startsWith("unit-heavy-")) {
|
||||
return defaultWorkerBudget.unitIsolated;
|
||||
}
|
||||
@ -1299,7 +1308,7 @@ if (serialPrefixRuns.length > 0) {
|
||||
const failedMacMiniParallel = await runEntriesWithLimit(
|
||||
deferredEntries,
|
||||
passthroughOptionArgs,
|
||||
isMacMiniProfile ? 3 : topLevelParallelLimit,
|
||||
3,
|
||||
);
|
||||
if (failedMacMiniParallel !== undefined) {
|
||||
process.exit(failedMacMiniParallel);
|
||||
|
||||
@ -231,3 +231,18 @@ export function packFilesByDuration(files, bucketCount, estimateDurationMs) {
|
||||
|
||||
return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0);
|
||||
}
|
||||
|
||||
export function dedupeFilesPreserveOrder(files, exclude = new Set()) {
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
if (exclude.has(file) || seen.has(file)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(file);
|
||||
result.push(file);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@ import {
|
||||
BILLING_ERROR_USER_MESSAGE,
|
||||
formatBillingErrorMessage,
|
||||
formatAssistantErrorText,
|
||||
getApiErrorPayloadFingerprint,
|
||||
formatRawAssistantErrorForUi,
|
||||
isRawApiErrorPayload,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import { makeAssistantMessageFixture } from "./test-helpers/assistant-message-fixtures.js";
|
||||
|
||||
@ -159,3 +161,14 @@ describe("formatRawAssistantErrorForUi", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("raw API error payload helpers", () => {
|
||||
it("recognizes provider-prefixed JSON payloads for observation fingerprints", () => {
|
||||
const raw =
|
||||
'Ollama API error: {"type":"error","error":{"type":"server_error","message":"Boom"},"request_id":"req_123"}';
|
||||
|
||||
expect(isRawApiErrorPayload(raw)).toBe(true);
|
||||
expect(getApiErrorPayloadFingerprint(raw)).toContain("server_error");
|
||||
expect(getApiErrorPayloadFingerprint(raw)).toContain("req_123");
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
extractLeadingHttpStatus,
|
||||
formatRawAssistantErrorForUi,
|
||||
isCloudflareOrHtmlErrorPage,
|
||||
parseApiErrorPayload,
|
||||
} from "../../shared/assistant-error-format.js";
|
||||
export {
|
||||
extractLeadingHttpStatus,
|
||||
@ -223,9 +224,6 @@ export function extractObservedOverflowTokenCount(errorMessage?: string): number
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Allow provider-wrapped API payloads such as "Ollama API error 400: {...}".
|
||||
const ERROR_PAYLOAD_PREFIX_RE =
|
||||
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i;
|
||||
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi;
|
||||
const ERROR_PREFIX_RE =
|
||||
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i;
|
||||
@ -482,63 +480,6 @@ function shouldRewriteContextOverflowText(raw: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
type ErrorPayload = Record<string, unknown>;
|
||||
|
||||
function isErrorPayloadObject(payload: unknown): payload is ErrorPayload {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return false;
|
||||
}
|
||||
const record = payload as ErrorPayload;
|
||||
if (record.type === "error") {
|
||||
return true;
|
||||
}
|
||||
if (typeof record.request_id === "string" || typeof record.requestId === "string") {
|
||||
return true;
|
||||
}
|
||||
if ("error" in record) {
|
||||
const err = record.error;
|
||||
if (err && typeof err === "object" && !Array.isArray(err)) {
|
||||
const errRecord = err as ErrorPayload;
|
||||
if (
|
||||
typeof errRecord.message === "string" ||
|
||||
typeof errRecord.type === "string" ||
|
||||
typeof errRecord.code === "string"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseApiErrorPayload(raw: string): ErrorPayload | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const candidates = [trimmed];
|
||||
if (ERROR_PAYLOAD_PREFIX_RE.test(trimmed)) {
|
||||
candidates.push(trimmed.replace(ERROR_PAYLOAD_PREFIX_RE, "").trim());
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.startsWith("{") || !candidate.endsWith("}")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(candidate) as unknown;
|
||||
if (isErrorPayloadObject(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getApiErrorPayloadFingerprint(raw?: string): string | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
|
||||
@ -76,6 +76,33 @@ describe("getSubagentDepthFromSessionStore", () => {
|
||||
expect(depth).toBe(2);
|
||||
});
|
||||
|
||||
it("accepts JSON5 syntax in the on-disk depth store for backward compatibility", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-subagent-depth-json5-"));
|
||||
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
|
||||
const storePath = storeTemplate.replaceAll("{agentId}", "main");
|
||||
fs.writeFileSync(
|
||||
storePath,
|
||||
`{
|
||||
// hand-edited legacy store
|
||||
"agent:main:subagent:flat": {
|
||||
sessionId: "subagent-flat",
|
||||
spawnDepth: 2,
|
||||
},
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const depth = getSubagentDepthFromSessionStore("subagent:flat", {
|
||||
cfg: {
|
||||
session: {
|
||||
store: storeTemplate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(depth).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to session-key segment counting when metadata is missing", () => {
|
||||
const key = "agent:main:subagent:flat";
|
||||
const depth = getSubagentDepthFromSessionStore(key, {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import JSON5 from "json5";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { getSubagentDepth, parseAgentSessionKey } from "../sessions/session-key-utils.js";
|
||||
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
|
||||
import { resolveDefaultAgentId } from "./agent-scope.js";
|
||||
|
||||
type SessionDepthEntry = {
|
||||
@ -37,7 +37,7 @@ function normalizeSessionKey(value: unknown): string | undefined {
|
||||
function readSessionStore(storePath: string): Record<string, SessionDepthEntry> {
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
const parsed = parseJsonWithJson5Fallback(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, SessionDepthEntry>;
|
||||
}
|
||||
|
||||
@ -92,6 +92,45 @@ export async function withTrustedWebSearchEndpoint<T>(
|
||||
);
|
||||
}
|
||||
|
||||
export async function postTrustedWebToolsJson<T>(
|
||||
params: {
|
||||
url: string;
|
||||
timeoutSeconds: number;
|
||||
apiKey: string;
|
||||
body: Record<string, unknown>;
|
||||
errorLabel: string;
|
||||
maxErrorBytes?: number;
|
||||
},
|
||||
parseResponse: (response: Response) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: params.url,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
const detail = await readResponseText(response, {
|
||||
maxBytes: params.maxErrorBytes ?? 64_000,
|
||||
});
|
||||
throw new Error(
|
||||
`${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`,
|
||||
);
|
||||
}
|
||||
return await parseResponse(response);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function throwWebSearchApiError(res: Response, providerLabel: string): Promise<never> {
|
||||
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
|
||||
const detail = detailResult.text;
|
||||
|
||||
@ -73,6 +73,58 @@ export function resolveThreadBindingMaxAgeMs(params: {
|
||||
return Math.floor(maxAgeHours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
type ThreadBindingLifecycleRecord = {
|
||||
boundAt: number;
|
||||
lastActivityAt: number;
|
||||
idleTimeoutMs?: number;
|
||||
maxAgeMs?: number;
|
||||
};
|
||||
|
||||
export function resolveThreadBindingLifecycle(params: {
|
||||
record: ThreadBindingLifecycleRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
defaultMaxAgeMs: number;
|
||||
}): {
|
||||
expiresAt?: number;
|
||||
reason?: "idle-expired" | "max-age-expired";
|
||||
} {
|
||||
const idleTimeoutMs =
|
||||
typeof params.record.idleTimeoutMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.idleTimeoutMs))
|
||||
: params.defaultIdleTimeoutMs;
|
||||
const maxAgeMs =
|
||||
typeof params.record.maxAgeMs === "number"
|
||||
? Math.max(0, Math.floor(params.record.maxAgeMs))
|
||||
: params.defaultMaxAgeMs;
|
||||
|
||||
const inactivityExpiresAt =
|
||||
idleTimeoutMs > 0
|
||||
? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs
|
||||
: undefined;
|
||||
const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined;
|
||||
|
||||
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
|
||||
return inactivityExpiresAt <= maxAgeExpiresAt
|
||||
? { expiresAt: inactivityExpiresAt, reason: "idle-expired" }
|
||||
: { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
if (inactivityExpiresAt != null) {
|
||||
return { expiresAt: inactivityExpiresAt, reason: "idle-expired" };
|
||||
}
|
||||
if (maxAgeExpiresAt != null) {
|
||||
return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function resolveThreadBindingEffectiveExpiresAt(params: {
|
||||
record: ThreadBindingLifecycleRecord;
|
||||
defaultIdleTimeoutMs: number;
|
||||
defaultMaxAgeMs: number;
|
||||
}): number | undefined {
|
||||
return resolveThreadBindingLifecycle(params).expiresAt;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingsEnabled(params: {
|
||||
channelEnabledRaw: unknown;
|
||||
sessionEnabledRaw: unknown;
|
||||
|
||||
@ -442,6 +442,15 @@ describe("config cli", () => {
|
||||
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects JSON5-only object syntax when strict parsing is enabled", async () => {
|
||||
await expect(
|
||||
runConfigCommand(["config", "set", "gateway.auth", "{mode:'token'}", "--strict-json"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts --strict-json with batch mode and applies batch payload", async () => {
|
||||
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
|
||||
setSnapshot(resolved, resolved);
|
||||
@ -470,6 +479,8 @@ describe("config cli", () => {
|
||||
expect(helpText).toContain("--strict-json");
|
||||
expect(helpText).toContain("--json");
|
||||
expect(helpText).toContain("Legacy alias for --strict-json");
|
||||
expect(helpText).toContain("Value (JSON/JSON5 or raw string)");
|
||||
expect(helpText).toContain("Strict JSON parsing (error instead of");
|
||||
expect(helpText).toContain("--ref-provider");
|
||||
expect(helpText).toContain("--provider-source");
|
||||
expect(helpText).toContain("--batch-json");
|
||||
|
||||
@ -159,9 +159,9 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (opts.strictJson) {
|
||||
try {
|
||||
return JSON5.parse(trimmed);
|
||||
return JSON.parse(trimmed);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err });
|
||||
throw new Error(`Failed to parse JSON value: ${String(err)}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1280,8 +1280,8 @@ export function registerConfigCli(program: Command) {
|
||||
.command("set")
|
||||
.description(CONFIG_SET_DESCRIPTION)
|
||||
.argument("[path]", "Config path (dot or bracket notation)")
|
||||
.argument("[value]", "Value (JSON5 or raw string)")
|
||||
.option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false)
|
||||
.argument("[value]", "Value (JSON/JSON5 or raw string)")
|
||||
.option("--strict-json", "Strict JSON parsing (error instead of raw string fallback)", false)
|
||||
.option("--json", "Legacy alias for --strict-json", false)
|
||||
.option(
|
||||
"--dry-run",
|
||||
|
||||
@ -99,7 +99,7 @@ function resolveUserPath(
|
||||
export const STATE_DIR = resolveStateDir();
|
||||
|
||||
/**
|
||||
* Config file path (JSON5).
|
||||
* Config file path (JSON or JSON5).
|
||||
* Can be overridden via OPENCLAW_CONFIG_PATH.
|
||||
* Default: ~/.openclaw/openclaw.json (or $OPENCLAW_STATE_DIR/openclaw.json)
|
||||
*/
|
||||
|
||||
@ -56,6 +56,38 @@ describe("cron store", () => {
|
||||
await expect(loadCronStore(store.storePath)).rejects.toThrow(/Failed to parse cron store/i);
|
||||
});
|
||||
|
||||
it("accepts JSON5 syntax when loading an existing cron store", async () => {
|
||||
const store = await makeStorePath();
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
store.storePath,
|
||||
`{
|
||||
// hand-edited legacy store
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
id: 'job-1',
|
||||
name: 'Job 1',
|
||||
enabled: true,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 1,
|
||||
schedule: { kind: 'every', everyMs: 60000 },
|
||||
sessionTarget: 'main',
|
||||
wakeMode: 'next-heartbeat',
|
||||
payload: { kind: 'systemEvent', text: 'tick-job-1' },
|
||||
state: {},
|
||||
},
|
||||
],
|
||||
}`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await expect(loadCronStore(store.storePath)).resolves.toMatchObject({
|
||||
version: 1,
|
||||
jobs: [{ id: "job-1", enabled: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create a backup file when saving unchanged content", async () => {
|
||||
const store = await makeStorePath();
|
||||
const payload = makeStore("job-1", true);
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { expandHomePrefix } from "../infra/home-dir.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
|
||||
import type { CronStoreFile } from "./types.js";
|
||||
|
||||
export const DEFAULT_CRON_DIR = path.join(CONFIG_DIR, "cron");
|
||||
@ -26,7 +26,7 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
|
||||
const raw = await fs.promises.readFile(storePath, "utf-8");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON5.parse(raw);
|
||||
parsed = parseJsonWithJson5Fallback(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse cron store at ${storePath}: ${String(err)}`, {
|
||||
cause: err,
|
||||
|
||||
@ -11,6 +11,7 @@ import { extractArchive, resolvePackedRootDir } from "./archive.js";
|
||||
let fixtureRoot = "";
|
||||
let fixtureCount = 0;
|
||||
const directorySymlinkType = process.platform === "win32" ? "junction" : undefined;
|
||||
const ARCHIVE_EXTRACT_TIMEOUT_MS = 15_000;
|
||||
|
||||
async function makeTempDir(prefix = "case") {
|
||||
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
|
||||
@ -67,7 +68,7 @@ async function expectExtractedSizeBudgetExceeded(params: {
|
||||
extractArchive({
|
||||
archivePath: params.archivePath,
|
||||
destDir: params.destDir,
|
||||
timeoutMs: params.timeoutMs ?? 5_000,
|
||||
timeoutMs: params.timeoutMs ?? ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
limits: { maxExtractedBytes: params.maxExtractedBytes },
|
||||
}),
|
||||
).rejects.toThrow("archive extracted size exceeds limit");
|
||||
@ -93,7 +94,11 @@ describe("archive utils", () => {
|
||||
fileName: "hello.txt",
|
||||
content: "hi",
|
||||
});
|
||||
await extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 });
|
||||
await extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
});
|
||||
const rootDir = await resolvePackedRootDir(extractDir);
|
||||
const content = await fs.readFile(path.join(rootDir, "hello.txt"), "utf-8");
|
||||
expect(content).toBe("hi");
|
||||
@ -118,7 +123,11 @@ describe("archive utils", () => {
|
||||
await createDirectorySymlink(realExtractDir, extractDir);
|
||||
|
||||
await expect(
|
||||
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
|
||||
extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "destination-symlink",
|
||||
} satisfies Partial<ArchiveSecurityError>);
|
||||
@ -135,7 +144,11 @@ describe("archive utils", () => {
|
||||
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
|
||||
|
||||
await expect(
|
||||
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
|
||||
extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
}),
|
||||
).rejects.toThrow(/(escapes destination|absolute)/i);
|
||||
});
|
||||
});
|
||||
@ -151,7 +164,11 @@ describe("archive utils", () => {
|
||||
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
|
||||
|
||||
await expect(
|
||||
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
|
||||
extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "destination-symlink-traversal",
|
||||
} satisfies Partial<ArchiveSecurityError>);
|
||||
@ -186,7 +203,11 @@ describe("archive utils", () => {
|
||||
timing: "after-realpath",
|
||||
run: async () => {
|
||||
await expect(
|
||||
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
|
||||
extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "destination-symlink-traversal",
|
||||
} satisfies Partial<ArchiveSecurityError>);
|
||||
@ -222,7 +243,11 @@ describe("archive utils", () => {
|
||||
|
||||
try {
|
||||
await expect(
|
||||
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
|
||||
extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "destination-symlink-traversal",
|
||||
} satisfies Partial<ArchiveSecurityError>);
|
||||
@ -245,7 +270,11 @@ describe("archive utils", () => {
|
||||
await tar.c({ cwd: insideDir, file: archivePath }, ["../outside.txt"]);
|
||||
|
||||
await expect(
|
||||
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
|
||||
extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
}),
|
||||
).rejects.toThrow(/escapes destination/i);
|
||||
});
|
||||
});
|
||||
@ -261,7 +290,11 @@ describe("archive utils", () => {
|
||||
await tar.c({ cwd: archiveRoot, file: archivePath }, ["escape"]);
|
||||
|
||||
await expect(
|
||||
extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }),
|
||||
extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "destination-symlink-traversal",
|
||||
} satisfies Partial<ArchiveSecurityError>);
|
||||
@ -308,7 +341,7 @@ describe("archive utils", () => {
|
||||
extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: 5_000,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
limits: { maxArchiveBytes: Math.max(1, stat.size - 1) },
|
||||
}),
|
||||
).rejects.toThrow("archive size exceeds limit");
|
||||
@ -328,7 +361,7 @@ describe("archive utils", () => {
|
||||
extractArchive({
|
||||
archivePath,
|
||||
destDir: extractDir,
|
||||
timeoutMs: 5_000,
|
||||
timeoutMs: ARCHIVE_EXTRACT_TIMEOUT_MS,
|
||||
}),
|
||||
).rejects.toThrow(/absolute|drive path|escapes destination/i);
|
||||
});
|
||||
|
||||
@ -36,6 +36,7 @@ describe("tsdown config", () => {
|
||||
expect.arrayContaining([
|
||||
"index",
|
||||
"plugins/runtime/index",
|
||||
"plugin-sdk/compat",
|
||||
"plugin-sdk/index",
|
||||
"extensions/openai/index",
|
||||
"bundled/boot-md/handler",
|
||||
|
||||
@ -3,6 +3,12 @@
|
||||
export { getAcpSessionManager } from "../acp/control-plane/manager.js";
|
||||
export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js";
|
||||
export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js";
|
||||
export {
|
||||
getAcpRuntimeBackend,
|
||||
registerAcpRuntimeBackend,
|
||||
requireAcpRuntimeBackend,
|
||||
unregisterAcpRuntimeBackend,
|
||||
} from "../acp/runtime/registry.js";
|
||||
export type {
|
||||
AcpRuntime,
|
||||
AcpRuntimeCapabilities,
|
||||
|
||||
@ -8,11 +8,11 @@ const shouldWarnCompatImport =
|
||||
|
||||
if (shouldWarnCompatImport) {
|
||||
process.emitWarning(
|
||||
"openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/<subpath> imports.",
|
||||
"openclaw/plugin-sdk/compat is deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/<subpath> imports. See https://docs.openclaw.ai/plugins/sdk-migration",
|
||||
{
|
||||
code: "OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED",
|
||||
detail:
|
||||
"Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating.",
|
||||
"Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration",
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -20,6 +20,8 @@ if (shouldWarnCompatImport) {
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||
export { delegateCompactionToRuntime } from "../context-engine/delegate.js";
|
||||
export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js";
|
||||
export { onDiagnosticEvent } from "../infra/diagnostic-events.js";
|
||||
|
||||
export { createAccountStatusSink } from "./channel-lifecycle.js";
|
||||
export { createPluginRuntimeStore } from "./runtime-store.js";
|
||||
|
||||
@ -51,6 +51,8 @@ export type {
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
ProviderAuthMethod,
|
||||
ProviderAuthResult,
|
||||
OpenClawPluginToolContext,
|
||||
OpenClawPluginToolFactory,
|
||||
OpenClawPluginCommandDefinition,
|
||||
OpenClawPluginDefinition,
|
||||
PluginCommandContext,
|
||||
|
||||
@ -50,9 +50,11 @@ describe("plugin-sdk exports", () => {
|
||||
it("keeps the root runtime surface intentionally small", () => {
|
||||
expect(typeof sdk.emptyPluginConfigSchema).toBe("function");
|
||||
expect(typeof sdk.delegateCompactionToRuntime).toBe("function");
|
||||
expect(typeof sdk.onDiagnosticEvent).toBe("function");
|
||||
expect(Object.prototype.hasOwnProperty.call(sdk, "resolveControlCommandGate")).toBe(false);
|
||||
expect(Object.prototype.hasOwnProperty.call(sdk, "buildAgentSessionKey")).toBe(false);
|
||||
expect(Object.prototype.hasOwnProperty.call(sdk, "isDangerousNameMatchingEnabled")).toBe(false);
|
||||
expect(Object.prototype.hasOwnProperty.call(sdk, "emitDiagnosticEvent")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps package.json plugin-sdk exports synced with the manifest", async () => {
|
||||
|
||||
@ -64,7 +64,9 @@ export type { HookEntry } from "../hooks/types.js";
|
||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||
export type { WizardPrompter } from "../wizard/prompts.js";
|
||||
export type { ContextEngineFactory } from "../context-engine/registry.js";
|
||||
export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js";
|
||||
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export { registerContextEngine } from "../context-engine/registry.js";
|
||||
export { delegateCompactionToRuntime } from "../context-engine/delegate.js";
|
||||
export { onDiagnosticEvent } from "../infra/diagnostic-events.js";
|
||||
|
||||
@ -7,6 +7,7 @@ export {
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
splitSetupEntries,
|
||||
} from "./setup.js";
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
export type { ChannelSetupAdapter, ChannelSetupDmPolicy, ChannelSetupWizard } from "./setup.js";
|
||||
export {
|
||||
listLineAccountIds,
|
||||
|
||||
@ -1,178 +1 @@
|
||||
// Narrow plugin-sdk surface for the bundled matrix plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/matrix.
|
||||
|
||||
import { createOptionalChannelSetupSurface } from "./channel-setup.js";
|
||||
|
||||
export {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "../agents/tools/common.js";
|
||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||
export { resolveAckReaction } from "../agents/identity.js";
|
||||
export {
|
||||
compileAllowlist,
|
||||
resolveCompiledAllowlistMatch,
|
||||
resolveAllowlistCandidates,
|
||||
resolveAllowlistMatchByCandidates,
|
||||
} from "../channels/allowlist-match.js";
|
||||
export {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
canonicalizeAllowlistWithResolvedIds,
|
||||
mergeAllowlist,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "../channels/allowlists/resolve-utils.js";
|
||||
export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js";
|
||||
export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js";
|
||||
export { resolveControlCommandGate } from "../channels/command-gating.js";
|
||||
export type { NormalizedLocation } from "../channels/location.js";
|
||||
export { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||
export { logInboundDrop, logTypingFailure } from "../channels/logging.js";
|
||||
export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js";
|
||||
export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js";
|
||||
export {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
} from "../channels/plugins/channel-config.js";
|
||||
export { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
|
||||
export {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../channels/plugins/config-helpers.js";
|
||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";
|
||||
export {
|
||||
buildSingleChannelSecretPromptState,
|
||||
addWildcardAllowFrom,
|
||||
mergeAllowFromEntries,
|
||||
promptAccountId,
|
||||
promptSingleChannelSecretInput,
|
||||
setTopLevelChannelGroupPolicy,
|
||||
} from "../channels/plugins/setup-wizard-helpers.js";
|
||||
export { promptChannelAccessConfig } from "../channels/plugins/setup-group-access.js";
|
||||
export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js";
|
||||
export {
|
||||
applyAccountNameToChannelSection,
|
||||
moveSingleAccountChannelSectionToDefaultAccount,
|
||||
} from "../channels/plugins/setup-helpers.js";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery,
|
||||
ChannelMessageToolSchemaContribution,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
ChannelSetupInput,
|
||||
ChannelToolSend,
|
||||
} from "../channels/plugins/types.js";
|
||||
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js";
|
||||
export {
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
} from "../channels/thread-bindings-policy.js";
|
||||
export {
|
||||
setMatrixThreadBindingIdleTimeoutBySessionKey,
|
||||
setMatrixThreadBindingMaxAgeBySessionKey,
|
||||
} from "../../extensions/matrix/thread-bindings-runtime.js";
|
||||
export { createTypingCallbacks } from "../channels/typing.js";
|
||||
export { createChannelReplyPipeline } from "./channel-reply-pipeline.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../config/runtime-group-policy.js";
|
||||
export type {
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownTableMode,
|
||||
} from "../config/types.js";
|
||||
export type { SecretInput } from "./secret-input.js";
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "./secret-input.js";
|
||||
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
|
||||
export { MarkdownConfigSchema } from "../config/zod-schema.core.js";
|
||||
export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js";
|
||||
export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
||||
export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js";
|
||||
export {
|
||||
getSessionBindingService,
|
||||
registerSessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
export { resolveOutboundSendDep } from "../infra/outbound/send-deps.js";
|
||||
export type {
|
||||
BindingTargetKind,
|
||||
SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
export { isPrivateOrLoopbackHost } from "../gateway/net.js";
|
||||
export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js";
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
|
||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
export type { PollInput } from "../polls.js";
|
||||
export { normalizePollInput } from "../polls.js";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
export type { RuntimeEnv } from "../runtime.js";
|
||||
export { normalizeStringEntries } from "../shared/string-normalization.js";
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
export { redactSensitiveText } from "../logging/redact.js";
|
||||
export type { WizardPrompter } from "../wizard/prompts.js";
|
||||
export {
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
} from "./group-access.js";
|
||||
export { createChannelPairingController } from "./channel-pairing.js";
|
||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||
export { runPluginCommandWithTimeout } from "./run-command.js";
|
||||
export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js";
|
||||
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
|
||||
export {
|
||||
buildProbeChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
} from "./status-helpers.js";
|
||||
export {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "../../extensions/matrix/helper-api.js";
|
||||
export { getMatrixScopedEnvVarNames } from "../../extensions/matrix/helper-api.js";
|
||||
export {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../../extensions/matrix/helper-api.js";
|
||||
|
||||
const matrixSetup = createOptionalChannelSetupSurface({
|
||||
channel: "matrix",
|
||||
label: "Matrix",
|
||||
npmSpec: "@openclaw/matrix",
|
||||
docsPath: "/channels/matrix",
|
||||
});
|
||||
|
||||
export const matrixSetupWizard = matrixSetup.setupWizard;
|
||||
export const matrixSetupAdapter = matrixSetup.setupAdapter;
|
||||
export * from "../plugins/runtime/runtime-matrix-contract.js";
|
||||
|
||||
@ -43,3 +43,7 @@ export {
|
||||
normalizeOptionalSecretInput,
|
||||
normalizeSecretInput,
|
||||
} from "../utils/normalize-secret-input.js";
|
||||
export {
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
} from "../secrets/provider-env-vars.js";
|
||||
|
||||
6
src/plugin-sdk/provider-env-vars.ts
Normal file
6
src/plugin-sdk/provider-env-vars.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// Public provider auth environment variable helpers for plugin runtimes.
|
||||
|
||||
export {
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
} from "../secrets/provider-env-vars.js";
|
||||
4
src/plugin-sdk/provider-google.ts
Normal file
4
src/plugin-sdk/provider-google.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// Public Google provider helpers shared by bundled Google extensions.
|
||||
|
||||
export { normalizeGoogleModelId } from "../agents/model-id-normalization.js";
|
||||
export { parseGeminiAuth } from "../infra/gemini-auth.js";
|
||||
@ -23,6 +23,7 @@ export {
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
postTrustedWebToolsJson,
|
||||
throwWebSearchApiError,
|
||||
withTrustedWebSearchEndpoint,
|
||||
writeCachedSearchPayload,
|
||||
|
||||
7
src/plugin-sdk/provider-zai-endpoint.ts
Normal file
7
src/plugin-sdk/provider-zai-endpoint.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// Public Z.AI endpoint detection helpers for provider plugins.
|
||||
|
||||
export {
|
||||
detectZaiEndpoint,
|
||||
type ZaiDetectedEndpoint,
|
||||
type ZaiEndpointId,
|
||||
} from "../plugins/provider-zai-endpoint.js";
|
||||
@ -5,6 +5,7 @@ const fs = require("node:fs");
|
||||
|
||||
let monolithicSdk = null;
|
||||
const jitiLoaders = new Map();
|
||||
const pluginSdkSubpathsCache = new Map();
|
||||
|
||||
function emptyPluginConfigSchema() {
|
||||
function error(message) {
|
||||
@ -61,6 +62,57 @@ function resolveControlCommandGate(params) {
|
||||
return { commandAuthorized, shouldBlock };
|
||||
}
|
||||
|
||||
function onDiagnosticEvent(listener) {
|
||||
const monolithic = loadMonolithicSdk();
|
||||
if (!monolithic || typeof monolithic.onDiagnosticEvent !== "function") {
|
||||
throw new Error("openclaw/plugin-sdk root alias could not resolve onDiagnosticEvent");
|
||||
}
|
||||
return monolithic.onDiagnosticEvent(listener);
|
||||
}
|
||||
|
||||
function getPackageRoot() {
|
||||
return path.resolve(__dirname, "..", "..");
|
||||
}
|
||||
|
||||
function listPluginSdkExportedSubpaths() {
|
||||
const packageRoot = getPackageRoot();
|
||||
if (pluginSdkSubpathsCache.has(packageRoot)) {
|
||||
return pluginSdkSubpathsCache.get(packageRoot);
|
||||
}
|
||||
|
||||
let subpaths = [];
|
||||
try {
|
||||
const packageJsonPath = path.join(packageRoot, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
subpaths = Object.keys(packageJson.exports ?? {})
|
||||
.filter((key) => key.startsWith("./plugin-sdk/"))
|
||||
.map((key) => key.slice("./plugin-sdk/".length));
|
||||
} catch {
|
||||
subpaths = [];
|
||||
}
|
||||
|
||||
pluginSdkSubpathsCache.set(packageRoot, subpaths);
|
||||
return subpaths;
|
||||
}
|
||||
|
||||
function buildPluginSdkAliasMap(useDist) {
|
||||
const packageRoot = getPackageRoot();
|
||||
const pluginSdkDir = path.join(packageRoot, useDist ? "dist" : "src", "plugin-sdk");
|
||||
const ext = useDist ? ".js" : ".ts";
|
||||
const aliasMap = {
|
||||
"openclaw/plugin-sdk": __filename,
|
||||
};
|
||||
|
||||
for (const subpath of listPluginSdkExportedSubpaths()) {
|
||||
const candidate = path.join(pluginSdkDir, `${subpath}${ext}`);
|
||||
if (fs.existsSync(candidate)) {
|
||||
aliasMap[`openclaw/plugin-sdk/${subpath}`] = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return aliasMap;
|
||||
}
|
||||
|
||||
function getJiti(tryNative) {
|
||||
if (jitiLoaders.has(tryNative)) {
|
||||
return jitiLoaders.get(tryNative);
|
||||
@ -68,6 +120,7 @@ function getJiti(tryNative) {
|
||||
|
||||
const { createJiti } = require("jiti");
|
||||
const jitiLoader = createJiti(__filename, {
|
||||
alias: buildPluginSdkAliasMap(tryNative),
|
||||
interopDefault: true,
|
||||
// Prefer Node's native sync ESM loader for built dist/plugin-sdk/*.js files
|
||||
// so local plugins do not create a second transpiled OpenClaw core graph.
|
||||
@ -107,6 +160,7 @@ function tryLoadMonolithicSdk() {
|
||||
|
||||
const fastExports = {
|
||||
emptyPluginConfigSchema,
|
||||
onDiagnosticEvent,
|
||||
resolveControlCommandGate,
|
||||
};
|
||||
|
||||
|
||||
@ -48,6 +48,12 @@ function loadRootAliasWithStubs(options?: {
|
||||
}
|
||||
if (id === "node:fs") {
|
||||
return {
|
||||
readFileSync: () =>
|
||||
JSON.stringify({
|
||||
exports: {
|
||||
"./plugin-sdk/group-access": { default: "./dist/plugin-sdk/group-access.js" },
|
||||
},
|
||||
}),
|
||||
existsSync: () => options?.distExists ?? false,
|
||||
};
|
||||
}
|
||||
@ -164,8 +170,27 @@ describe("plugin-sdk root alias", () => {
|
||||
expect("delegateCompactionToRuntime" in lazyRootSdk).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards onDiagnosticEvent through the compat-backed root alias", () => {
|
||||
const onDiagnosticEvent = () => () => undefined;
|
||||
const lazyModule = loadRootAliasWithStubs({
|
||||
monolithicExports: {
|
||||
onDiagnosticEvent,
|
||||
},
|
||||
});
|
||||
const lazyRootSdk = lazyModule.moduleExports;
|
||||
|
||||
expect(typeof lazyRootSdk.onDiagnosticEvent).toBe("function");
|
||||
expect(
|
||||
typeof (lazyRootSdk.onDiagnosticEvent as (listener: () => void) => () => void)(
|
||||
() => undefined,
|
||||
),
|
||||
).toBe("function");
|
||||
expect("onDiagnosticEvent" in lazyRootSdk).toBe(true);
|
||||
});
|
||||
|
||||
it("loads legacy root exports through the merged root wrapper", { timeout: 240_000 }, () => {
|
||||
expect(typeof rootSdk.resolveControlCommandGate).toBe("function");
|
||||
expect(typeof rootSdk.onDiagnosticEvent).toBe("function");
|
||||
expect(typeof rootSdk.default).toBe("object");
|
||||
expect(rootSdk.default).toBe(rootSdk);
|
||||
expect(rootSdk.__esModule).toBe(true);
|
||||
@ -173,9 +198,12 @@ describe("plugin-sdk root alias", () => {
|
||||
|
||||
it("preserves reflection semantics for lazily resolved exports", { timeout: 240_000 }, () => {
|
||||
expect("resolveControlCommandGate" in rootSdk).toBe(true);
|
||||
expect("onDiagnosticEvent" in rootSdk).toBe(true);
|
||||
const keys = Object.keys(rootSdk);
|
||||
expect(keys).toContain("resolveControlCommandGate");
|
||||
expect(keys).toContain("onDiagnosticEvent");
|
||||
const descriptor = Object.getOwnPropertyDescriptor(rootSdk, "resolveControlCommandGate");
|
||||
expect(descriptor).toBeDefined();
|
||||
expect(Object.getOwnPropertyDescriptor(rootSdk, "onDiagnosticEvent")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -34,13 +34,14 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
|
||||
'export { probeIMessage } from "./src/probe.js";',
|
||||
'export { sendMessageIMessage } from "./src/send.js";',
|
||||
],
|
||||
"extensions/googlechat/runtime-api.ts": ['export * from "../../src/plugin-sdk/googlechat.js";'],
|
||||
"extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'],
|
||||
"extensions/matrix/runtime-api.ts": [
|
||||
'export * from "./src/auth-precedence.js";',
|
||||
'export * from "./helper-api.js";',
|
||||
'export * from "./thread-bindings-runtime.js";',
|
||||
],
|
||||
"extensions/nextcloud-talk/runtime-api.ts": [
|
||||
'export * from "../../src/plugin-sdk/nextcloud-talk.js";',
|
||||
'export * from "openclaw/plugin-sdk/nextcloud-talk";',
|
||||
],
|
||||
"extensions/signal/runtime-api.ts": ['export * from "./src/runtime-api.js";'],
|
||||
"extensions/slack/runtime-api.ts": [
|
||||
|
||||
@ -61,30 +61,15 @@ describe("plugin-sdk subpath exports", () => {
|
||||
expect(pluginSdkSubpaths).not.toContain("acpx");
|
||||
expect(pluginSdkSubpaths).not.toContain("compat");
|
||||
expect(pluginSdkSubpaths).not.toContain("device-pair");
|
||||
expect(pluginSdkSubpaths).not.toContain("feishu");
|
||||
expect(pluginSdkSubpaths).not.toContain("google");
|
||||
expect(pluginSdkSubpaths).not.toContain("googlechat");
|
||||
expect(pluginSdkSubpaths).not.toContain("irc");
|
||||
expect(pluginSdkSubpaths).not.toContain("line");
|
||||
expect(pluginSdkSubpaths).not.toContain("line-core");
|
||||
expect(pluginSdkSubpaths).not.toContain("lobster");
|
||||
expect(pluginSdkSubpaths).not.toContain("mattermost");
|
||||
expect(pluginSdkSubpaths).not.toContain("msteams");
|
||||
expect(pluginSdkSubpaths).not.toContain("nextcloud-talk");
|
||||
expect(pluginSdkSubpaths).not.toContain("nostr");
|
||||
expect(pluginSdkSubpaths).not.toContain("pairing-access");
|
||||
expect(pluginSdkSubpaths).not.toContain("qwen-portal-auth");
|
||||
expect(pluginSdkSubpaths).not.toContain("reply-prefix");
|
||||
expect(pluginSdkSubpaths).not.toContain("signal");
|
||||
expect(pluginSdkSubpaths).not.toContain("signal-core");
|
||||
expect(pluginSdkSubpaths).not.toContain("synology-chat");
|
||||
expect(pluginSdkSubpaths).not.toContain("tlon");
|
||||
expect(pluginSdkSubpaths).not.toContain("twitch");
|
||||
expect(pluginSdkSubpaths).not.toContain("typing");
|
||||
expect(pluginSdkSubpaths).not.toContain("voice-call");
|
||||
expect(pluginSdkSubpaths).not.toContain("zalo");
|
||||
expect(pluginSdkSubpaths).not.toContain("zai");
|
||||
expect(pluginSdkSubpaths).not.toContain("zalouser");
|
||||
expect(pluginSdkSubpaths).not.toContain("provider-model-definitions");
|
||||
});
|
||||
|
||||
|
||||
@ -1,127 +1 @@
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelPlugin,
|
||||
} from "../channels/plugins/types.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export type { PluginRuntime } from "../plugins/runtime/types.js";
|
||||
export type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
export type {
|
||||
TelegramAccountConfig,
|
||||
TelegramActionConfig,
|
||||
TelegramNetworkConfig,
|
||||
} from "../config/types.js";
|
||||
export type {
|
||||
ChannelConfiguredBindingProvider,
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
export type { InspectedTelegramAccount } from "../../extensions/telegram/api.js";
|
||||
export type { ResolvedTelegramAccount } from "../../extensions/telegram/api.js";
|
||||
export type { TelegramProbe } from "../../extensions/telegram/runtime-api.js";
|
||||
export type { TelegramButtonStyle, TelegramInlineButtons } from "../../extensions/telegram/api.js";
|
||||
export type { StickerMetadata } from "../../extensions/telegram/api.js";
|
||||
|
||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
export { parseTelegramTopicConversation } from "../acp/conversation-id.js";
|
||||
export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js";
|
||||
export { resolveTelegramPollVisibility } from "../poll-params.js";
|
||||
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "./channel-plugin-common.js";
|
||||
|
||||
export {
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
} from "../channels/account-snapshot-fields.js";
|
||||
export {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
} from "../config/runtime-group-policy.js";
|
||||
export {
|
||||
listTelegramDirectoryGroupsFromConfig,
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
export {
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
export { TelegramConfigSchema } from "../config/zod-schema.providers-core.js";
|
||||
|
||||
export { buildTokenChannelStatusSummary } from "./status-helpers.js";
|
||||
|
||||
export {
|
||||
createTelegramActionGate,
|
||||
listTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramPollActionGateState,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
export { inspectTelegramAccount } from "../../extensions/telegram/api.js";
|
||||
export {
|
||||
looksLikeTelegramTargetId,
|
||||
normalizeTelegramMessagingTarget,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
export {
|
||||
parseTelegramReplyToMessageId,
|
||||
parseTelegramThreadId,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
export {
|
||||
isNumericTelegramUserId,
|
||||
normalizeTelegramAllowFromEntry,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
export { fetchTelegramChatId } from "../../extensions/telegram/api.js";
|
||||
export {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
resolveTelegramTargetChatType,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
export { resolveTelegramReactionLevel } from "../../extensions/telegram/api.js";
|
||||
export {
|
||||
auditTelegramGroupMembership,
|
||||
collectTelegramUnmentionedGroupIds,
|
||||
createForumTopicTelegram,
|
||||
deleteMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
editMessageReplyMarkupTelegram,
|
||||
editMessageTelegram,
|
||||
monitorTelegramProvider,
|
||||
pinMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
renameForumTopicTelegram,
|
||||
probeTelegram,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
sendTypingTelegram,
|
||||
unpinMessageTelegram,
|
||||
} from "../../extensions/telegram/runtime-api.js";
|
||||
export { getCacheStats, searchStickers } from "../../extensions/telegram/api.js";
|
||||
export { resolveTelegramToken } from "../../extensions/telegram/runtime-api.js";
|
||||
export { telegramMessageActions } from "../../extensions/telegram/runtime-api.js";
|
||||
export {
|
||||
setTelegramThreadBindingIdleTimeoutBySessionKey,
|
||||
setTelegramThreadBindingMaxAgeBySessionKey,
|
||||
} from "../../extensions/telegram/runtime-api.js";
|
||||
export { collectTelegramStatusIssues } from "../../extensions/telegram/api.js";
|
||||
export { sendTelegramPayloadMessages } from "../../extensions/telegram/api.js";
|
||||
export {
|
||||
buildBrowseProvidersButton,
|
||||
buildModelsKeyboard,
|
||||
buildProviderKeyboard,
|
||||
calculateTotalPages,
|
||||
getModelsPageSize,
|
||||
type ProviderInfo,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
export {
|
||||
isTelegramExecApprovalApprover,
|
||||
isTelegramExecApprovalClientEnabled,
|
||||
} from "../../extensions/telegram/api.js";
|
||||
export * from "../plugins/runtime/runtime-telegram-contract.js";
|
||||
|
||||
@ -13,6 +13,7 @@ export * from "../shared/global-singleton.js";
|
||||
export * from "../shared/string-normalization.js";
|
||||
export * from "../shared/string-sample.js";
|
||||
export * from "../shared/text/assistant-visible-text.js";
|
||||
export * from "../shared/text/auto-linked-file-ref.js";
|
||||
export * from "../shared/text/code-regions.js";
|
||||
export * from "../shared/text/reasoning-tags.js";
|
||||
export * from "../terminal/safe-text.js";
|
||||
|
||||
@ -193,7 +193,7 @@ const hasExplicitMemorySlot = (plugins?: OpenClawConfig["plugins"]) =>
|
||||
const hasExplicitMemoryEntry = (plugins?: OpenClawConfig["plugins"]) =>
|
||||
Boolean(plugins?.entries && Object.prototype.hasOwnProperty.call(plugins.entries, "memory-core"));
|
||||
|
||||
const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) => {
|
||||
export const hasExplicitPluginConfig = (plugins?: OpenClawConfig["plugins"]) => {
|
||||
if (!plugins) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
withBundledPluginAllowlistCompat,
|
||||
withBundledPluginEnablementCompat,
|
||||
} from "./bundled-compat.js";
|
||||
import { hasExplicitPluginConfig } from "./config-state.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
|
||||
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
|
||||
import { createPluginLoaderLogger } from "./logger.js";
|
||||
@ -12,39 +13,17 @@ import type { ProviderPlugin } from "./types.js";
|
||||
|
||||
const log = createSubsystemLogger("plugins");
|
||||
|
||||
function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean {
|
||||
const plugins = config?.plugins;
|
||||
if (!plugins) {
|
||||
return false;
|
||||
}
|
||||
if (typeof plugins.enabled === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function withBundledProviderVitestCompat(params: {
|
||||
config: PluginLoadOptions["config"];
|
||||
pluginIds: readonly string[];
|
||||
env?: PluginLoadOptions["env"];
|
||||
}): PluginLoadOptions["config"] {
|
||||
const env = params.env ?? process.env;
|
||||
if (!env.VITEST || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) {
|
||||
if (
|
||||
!env.VITEST ||
|
||||
hasExplicitPluginConfig(params.config?.plugins) ||
|
||||
params.pluginIds.length === 0
|
||||
) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
|
||||
178
src/plugins/runtime/runtime-matrix-contract.ts
Normal file
178
src/plugins/runtime/runtime-matrix-contract.ts
Normal file
@ -0,0 +1,178 @@
|
||||
// Narrow plugin-sdk surface for the bundled matrix plugin.
|
||||
// Keep this list additive and scoped to symbols used under extensions/matrix.
|
||||
|
||||
import { createOptionalChannelSetupSurface } from "../../plugin-sdk/channel-setup.js";
|
||||
|
||||
export {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "../../agents/tools/common.js";
|
||||
export type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
export { resolveAckReaction } from "../../agents/identity.js";
|
||||
export {
|
||||
compileAllowlist,
|
||||
resolveCompiledAllowlistMatch,
|
||||
resolveAllowlistCandidates,
|
||||
resolveAllowlistMatchByCandidates,
|
||||
} from "../../channels/allowlist-match.js";
|
||||
export {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
canonicalizeAllowlistWithResolvedIds,
|
||||
mergeAllowlist,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "../../channels/allowlists/resolve-utils.js";
|
||||
export { ensureConfiguredAcpBindingReady } from "../../acp/persistent-bindings.lifecycle.js";
|
||||
export { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.resolve.js";
|
||||
export { resolveControlCommandGate } from "../../channels/command-gating.js";
|
||||
export type { NormalizedLocation } from "../../channels/location.js";
|
||||
export { formatLocationText, toLocationContext } from "../../channels/location.js";
|
||||
export { logInboundDrop, logTypingFailure } from "../../channels/logging.js";
|
||||
export type { AllowlistMatch } from "../../channels/plugins/allowlist-match.js";
|
||||
export { formatAllowlistMatchMeta } from "../../channels/plugins/allowlist-match.js";
|
||||
export {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
} from "../../channels/plugins/channel-config.js";
|
||||
export { createAccountListHelpers } from "../../channels/plugins/account-helpers.js";
|
||||
export {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../../channels/plugins/config-helpers.js";
|
||||
export { buildChannelConfigSchema } from "../../channels/plugins/config-schema.js";
|
||||
export { formatPairingApproveHint } from "../../channels/plugins/helpers.js";
|
||||
export {
|
||||
buildSingleChannelSecretPromptState,
|
||||
addWildcardAllowFrom,
|
||||
mergeAllowFromEntries,
|
||||
promptAccountId,
|
||||
promptSingleChannelSecretInput,
|
||||
setTopLevelChannelGroupPolicy,
|
||||
} from "../../channels/plugins/setup-wizard-helpers.js";
|
||||
export { promptChannelAccessConfig } from "../../channels/plugins/setup-group-access.js";
|
||||
export { PAIRING_APPROVED_MESSAGE } from "../../channels/plugins/pairing-message.js";
|
||||
export {
|
||||
applyAccountNameToChannelSection,
|
||||
moveSingleAccountChannelSectionToDefaultAccount,
|
||||
} from "../../channels/plugins/setup-helpers.js";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery,
|
||||
ChannelMessageToolSchemaContribution,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
ChannelSetupInput,
|
||||
ChannelToolSend,
|
||||
} from "../../channels/plugins/types.js";
|
||||
export type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
export { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
export { resolveThreadBindingFarewellText } from "../../channels/thread-bindings-messages.js";
|
||||
export {
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
} from "../../channels/thread-bindings-policy.js";
|
||||
export {
|
||||
setMatrixThreadBindingIdleTimeoutBySessionKey,
|
||||
setMatrixThreadBindingMaxAgeBySessionKey,
|
||||
} from "../../../extensions/matrix/runtime-api.js";
|
||||
export { createTypingCallbacks } from "../../channels/typing.js";
|
||||
export { createChannelReplyPipeline } from "../../plugin-sdk/channel-reply-pipeline.js";
|
||||
export type { OpenClawConfig } from "../../config/config.js";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../../config/runtime-group-policy.js";
|
||||
export type {
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownTableMode,
|
||||
} from "../../config/types.js";
|
||||
export type { SecretInput } from "../../plugin-sdk/secret-input.js";
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "../../plugin-sdk/secret-input.js";
|
||||
export { ToolPolicySchema } from "../../config/zod-schema.agent-runtime.js";
|
||||
export { MarkdownConfigSchema } from "../../config/zod-schema.core.js";
|
||||
export { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
|
||||
export { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js";
|
||||
export { maybeCreateMatrixMigrationSnapshot } from "../../infra/matrix-migration-snapshot.js";
|
||||
export {
|
||||
getSessionBindingService,
|
||||
registerSessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
} from "../../infra/outbound/session-binding-service.js";
|
||||
export { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js";
|
||||
export type {
|
||||
BindingTargetKind,
|
||||
SessionBindingRecord,
|
||||
} from "../../infra/outbound/session-binding-service.js";
|
||||
export { isPrivateOrLoopbackHost } from "../../gateway/net.js";
|
||||
export { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
|
||||
export { emptyPluginConfigSchema } from "../config-schema.js";
|
||||
export type { PluginRuntime, RuntimeLogger } from "./types.js";
|
||||
export type { OpenClawPluginApi } from "../types.js";
|
||||
export type { PollInput } from "../../polls.js";
|
||||
export { normalizePollInput } from "../../polls.js";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
export type { RuntimeEnv } from "../../runtime.js";
|
||||
export { normalizeStringEntries } from "../../shared/string-normalization.js";
|
||||
export { formatDocsLink } from "../../terminal/links.js";
|
||||
export { redactSensitiveText } from "../../logging/redact.js";
|
||||
export type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
export {
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
} from "../../plugin-sdk/group-access.js";
|
||||
export { createChannelPairingController } from "../../plugin-sdk/channel-pairing.js";
|
||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "../../plugin-sdk/json-store.js";
|
||||
export { formatResolvedUnresolvedNote } from "../../plugin-sdk/resolution-notes.js";
|
||||
export { runPluginCommandWithTimeout } from "../../plugin-sdk/run-command.js";
|
||||
export { createLoggerBackedRuntime, resolveRuntimeEnv } from "../../plugin-sdk/runtime.js";
|
||||
export { dispatchReplyFromConfigWithSettledDispatcher } from "../../plugin-sdk/inbound-reply-dispatch.js";
|
||||
export {
|
||||
buildProbeChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
} from "../../plugin-sdk/status-helpers.js";
|
||||
export {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "../../../extensions/matrix/runtime-api.js";
|
||||
export { getMatrixScopedEnvVarNames } from "../../../extensions/matrix/runtime-api.js";
|
||||
export {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../../../extensions/matrix/runtime-api.js";
|
||||
|
||||
const matrixSetup = createOptionalChannelSetupSurface({
|
||||
channel: "matrix",
|
||||
label: "Matrix",
|
||||
npmSpec: "@openclaw/matrix",
|
||||
docsPath: "/channels/matrix",
|
||||
});
|
||||
|
||||
export const matrixSetupWizard = matrixSetup.setupWizard;
|
||||
export const matrixSetupAdapter = matrixSetup.setupAdapter;
|
||||
130
src/plugins/runtime/runtime-telegram-contract.ts
Normal file
130
src/plugins/runtime/runtime-telegram-contract.ts
Normal file
@ -0,0 +1,130 @@
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
ChannelMessageActionAdapter,
|
||||
} from "../../channels/plugins/types.js";
|
||||
export type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
export type { OpenClawConfig } from "../../config/config.js";
|
||||
export type { PluginRuntime } from "./types.js";
|
||||
export type { OpenClawPluginApi } from "../types.js";
|
||||
export type {
|
||||
TelegramAccountConfig,
|
||||
TelegramActionConfig,
|
||||
TelegramNetworkConfig,
|
||||
} from "../../config/types.js";
|
||||
export type {
|
||||
ChannelConfiguredBindingProvider,
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
} from "../../channels/plugins/types.adapters.js";
|
||||
export type { InspectedTelegramAccount } from "../../../extensions/telegram/api.js";
|
||||
export type { ResolvedTelegramAccount } from "../../../extensions/telegram/api.js";
|
||||
export type { TelegramProbe } from "../../../extensions/telegram/runtime-api.js";
|
||||
export type {
|
||||
TelegramButtonStyle,
|
||||
TelegramInlineButtons,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
export type { StickerMetadata } from "../../../extensions/telegram/api.js";
|
||||
|
||||
export { emptyPluginConfigSchema } from "../config-schema.js";
|
||||
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
export { parseTelegramTopicConversation } from "../../acp/conversation-id.js";
|
||||
export { clearAccountEntryFields } from "../../channels/plugins/config-helpers.js";
|
||||
export { resolveTelegramPollVisibility } from "../../poll-params.js";
|
||||
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../../plugin-sdk/channel-plugin-common.js";
|
||||
|
||||
export {
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
} from "../../channels/account-snapshot-fields.js";
|
||||
export {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
} from "../../config/runtime-group-policy.js";
|
||||
export {
|
||||
listTelegramDirectoryGroupsFromConfig,
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
export {
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
export { TelegramConfigSchema } from "../../config/zod-schema.providers-core.js";
|
||||
|
||||
export { buildTokenChannelStatusSummary } from "../../plugin-sdk/status-helpers.js";
|
||||
|
||||
export {
|
||||
createTelegramActionGate,
|
||||
listTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramPollActionGateState,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
export { inspectTelegramAccount } from "../../../extensions/telegram/api.js";
|
||||
export {
|
||||
looksLikeTelegramTargetId,
|
||||
normalizeTelegramMessagingTarget,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
export {
|
||||
parseTelegramReplyToMessageId,
|
||||
parseTelegramThreadId,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
export {
|
||||
isNumericTelegramUserId,
|
||||
normalizeTelegramAllowFromEntry,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
export { fetchTelegramChatId } from "../../../extensions/telegram/api.js";
|
||||
export {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
resolveTelegramTargetChatType,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
export { resolveTelegramReactionLevel } from "../../../extensions/telegram/api.js";
|
||||
export {
|
||||
auditTelegramGroupMembership,
|
||||
collectTelegramUnmentionedGroupIds,
|
||||
createForumTopicTelegram,
|
||||
deleteMessageTelegram,
|
||||
editForumTopicTelegram,
|
||||
editMessageReplyMarkupTelegram,
|
||||
editMessageTelegram,
|
||||
monitorTelegramProvider,
|
||||
pinMessageTelegram,
|
||||
reactMessageTelegram,
|
||||
renameForumTopicTelegram,
|
||||
probeTelegram,
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
sendTypingTelegram,
|
||||
unpinMessageTelegram,
|
||||
} from "../../../extensions/telegram/runtime-api.js";
|
||||
export { getCacheStats, searchStickers } from "../../../extensions/telegram/api.js";
|
||||
export { resolveTelegramToken } from "../../../extensions/telegram/runtime-api.js";
|
||||
export { telegramMessageActions } from "../../../extensions/telegram/runtime-api.js";
|
||||
export {
|
||||
setTelegramThreadBindingIdleTimeoutBySessionKey,
|
||||
setTelegramThreadBindingMaxAgeBySessionKey,
|
||||
} from "../../../extensions/telegram/runtime-api.js";
|
||||
export { collectTelegramStatusIssues } from "../../../extensions/telegram/api.js";
|
||||
export { sendTelegramPayloadMessages } from "../../../extensions/telegram/api.js";
|
||||
export {
|
||||
buildBrowseProvidersButton,
|
||||
buildModelsKeyboard,
|
||||
buildProviderKeyboard,
|
||||
calculateTotalPages,
|
||||
getModelsPageSize,
|
||||
type ProviderInfo,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
export {
|
||||
isTelegramExecApprovalApprover,
|
||||
isTelegramExecApprovalClientEnabled,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
@ -94,29 +94,29 @@ export type PluginRuntimeChannel = {
|
||||
shouldHandleTextCommands: typeof import("../../auto-reply/commands-registry.js").shouldHandleTextCommands;
|
||||
};
|
||||
discord: {
|
||||
messageActions: typeof import("../../../extensions/discord/runtime-api.js").discordMessageActions;
|
||||
auditChannelPermissions: typeof import("../../../extensions/discord/runtime-api.js").auditDiscordChannelPermissions;
|
||||
listDirectoryGroupsLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryGroupsLive;
|
||||
listDirectoryPeersLive: typeof import("../../../extensions/discord/runtime-api.js").listDiscordDirectoryPeersLive;
|
||||
probeDiscord: typeof import("../../../extensions/discord/runtime-api.js").probeDiscord;
|
||||
resolveChannelAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordChannelAllowlist;
|
||||
resolveUserAllowlist: typeof import("../../../extensions/discord/runtime-api.js").resolveDiscordUserAllowlist;
|
||||
sendComponentMessage: typeof import("../../../extensions/discord/runtime-api.js").sendDiscordComponentMessage;
|
||||
sendMessageDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendMessageDiscord;
|
||||
sendPollDiscord: typeof import("../../../extensions/discord/runtime-api.js").sendPollDiscord;
|
||||
monitorDiscordProvider: typeof import("../../../extensions/discord/runtime-api.js").monitorDiscordProvider;
|
||||
messageActions: typeof import("../../plugin-sdk/discord.js").discordMessageActions;
|
||||
auditChannelPermissions: typeof import("../../plugin-sdk/discord.js").auditDiscordChannelPermissions;
|
||||
listDirectoryGroupsLive: typeof import("../../plugin-sdk/discord.js").listDiscordDirectoryGroupsLive;
|
||||
listDirectoryPeersLive: typeof import("../../plugin-sdk/discord.js").listDiscordDirectoryPeersLive;
|
||||
probeDiscord: typeof import("../../plugin-sdk/discord.js").probeDiscord;
|
||||
resolveChannelAllowlist: typeof import("../../plugin-sdk/discord.js").resolveDiscordChannelAllowlist;
|
||||
resolveUserAllowlist: typeof import("../../plugin-sdk/discord.js").resolveDiscordUserAllowlist;
|
||||
sendComponentMessage: typeof import("../../plugin-sdk/discord.js").sendDiscordComponentMessage;
|
||||
sendMessageDiscord: typeof import("../../plugin-sdk/discord.js").sendMessageDiscord;
|
||||
sendPollDiscord: typeof import("../../plugin-sdk/discord.js").sendPollDiscord;
|
||||
monitorDiscordProvider: typeof import("../../plugin-sdk/discord.js").monitorDiscordProvider;
|
||||
threadBindings: {
|
||||
getManager: typeof import("../../../extensions/discord/runtime-api.js").getThreadBindingManager;
|
||||
resolveIdleTimeoutMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingIdleTimeoutMs;
|
||||
resolveInactivityExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingInactivityExpiresAt;
|
||||
resolveMaxAgeMs: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeMs;
|
||||
resolveMaxAgeExpiresAt: typeof import("../../../extensions/discord/runtime-api.js").resolveThreadBindingMaxAgeExpiresAt;
|
||||
setIdleTimeoutBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingIdleTimeoutBySessionKey;
|
||||
setMaxAgeBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").setThreadBindingMaxAgeBySessionKey;
|
||||
unbindBySessionKey: typeof import("../../../extensions/discord/runtime-api.js").unbindThreadBindingsBySessionKey;
|
||||
getManager: typeof import("../../plugin-sdk/discord.js").getThreadBindingManager;
|
||||
resolveIdleTimeoutMs: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingIdleTimeoutMs;
|
||||
resolveInactivityExpiresAt: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingInactivityExpiresAt;
|
||||
resolveMaxAgeMs: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingMaxAgeMs;
|
||||
resolveMaxAgeExpiresAt: typeof import("../../plugin-sdk/discord.js").resolveThreadBindingMaxAgeExpiresAt;
|
||||
setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/discord.js").setThreadBindingIdleTimeoutBySessionKey;
|
||||
setMaxAgeBySessionKey: typeof import("../../plugin-sdk/discord.js").setThreadBindingMaxAgeBySessionKey;
|
||||
unbindBySessionKey: typeof import("../../plugin-sdk/discord.js").unbindThreadBindingsBySessionKey;
|
||||
};
|
||||
typing: {
|
||||
pulse: typeof import("../../../extensions/discord/runtime-api.js").sendTypingDiscord;
|
||||
pulse: typeof import("../../plugin-sdk/discord.js").sendTypingDiscord;
|
||||
start: (params: {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
@ -128,39 +128,39 @@ export type PluginRuntimeChannel = {
|
||||
}>;
|
||||
};
|
||||
conversationActions: {
|
||||
editMessage: typeof import("../../../extensions/discord/runtime-api.js").editMessageDiscord;
|
||||
deleteMessage: typeof import("../../../extensions/discord/runtime-api.js").deleteMessageDiscord;
|
||||
pinMessage: typeof import("../../../extensions/discord/runtime-api.js").pinMessageDiscord;
|
||||
unpinMessage: typeof import("../../../extensions/discord/runtime-api.js").unpinMessageDiscord;
|
||||
createThread: typeof import("../../../extensions/discord/runtime-api.js").createThreadDiscord;
|
||||
editChannel: typeof import("../../../extensions/discord/runtime-api.js").editChannelDiscord;
|
||||
editMessage: typeof import("../../plugin-sdk/discord.js").editMessageDiscord;
|
||||
deleteMessage: typeof import("../../plugin-sdk/discord.js").deleteMessageDiscord;
|
||||
pinMessage: typeof import("../../plugin-sdk/discord.js").pinMessageDiscord;
|
||||
unpinMessage: typeof import("../../plugin-sdk/discord.js").unpinMessageDiscord;
|
||||
createThread: typeof import("../../plugin-sdk/discord.js").createThreadDiscord;
|
||||
editChannel: typeof import("../../plugin-sdk/discord.js").editChannelDiscord;
|
||||
};
|
||||
};
|
||||
slack: {
|
||||
listDirectoryGroupsLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryGroupsLive;
|
||||
listDirectoryPeersLive: typeof import("../../../extensions/slack/runtime-api.js").listSlackDirectoryPeersLive;
|
||||
probeSlack: typeof import("../../../extensions/slack/runtime-api.js").probeSlack;
|
||||
resolveChannelAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackChannelAllowlist;
|
||||
resolveUserAllowlist: typeof import("../../../extensions/slack/runtime-api.js").resolveSlackUserAllowlist;
|
||||
sendMessageSlack: typeof import("../../../extensions/slack/runtime-api.js").sendMessageSlack;
|
||||
monitorSlackProvider: typeof import("../../../extensions/slack/runtime-api.js").monitorSlackProvider;
|
||||
handleSlackAction: typeof import("../../../extensions/slack/runtime-api.js").handleSlackAction;
|
||||
listDirectoryGroupsLive: typeof import("../../plugin-sdk/slack.js").listSlackDirectoryGroupsLive;
|
||||
listDirectoryPeersLive: typeof import("../../plugin-sdk/slack.js").listSlackDirectoryPeersLive;
|
||||
probeSlack: typeof import("../../plugin-sdk/slack.js").probeSlack;
|
||||
resolveChannelAllowlist: typeof import("../../plugin-sdk/slack.js").resolveSlackChannelAllowlist;
|
||||
resolveUserAllowlist: typeof import("../../plugin-sdk/slack.js").resolveSlackUserAllowlist;
|
||||
sendMessageSlack: typeof import("../../plugin-sdk/slack.js").sendMessageSlack;
|
||||
monitorSlackProvider: typeof import("../../plugin-sdk/slack.js").monitorSlackProvider;
|
||||
handleSlackAction: typeof import("../../plugin-sdk/slack.js").handleSlackAction;
|
||||
};
|
||||
telegram: {
|
||||
auditGroupMembership: typeof import("../../../extensions/telegram/runtime-api.js").auditTelegramGroupMembership;
|
||||
collectUnmentionedGroupIds: typeof import("../../../extensions/telegram/runtime-api.js").collectTelegramUnmentionedGroupIds;
|
||||
probeTelegram: typeof import("../../../extensions/telegram/runtime-api.js").probeTelegram;
|
||||
resolveTelegramToken: typeof import("../../../extensions/telegram/runtime-api.js").resolveTelegramToken;
|
||||
sendMessageTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendMessageTelegram;
|
||||
sendPollTelegram: typeof import("../../../extensions/telegram/runtime-api.js").sendPollTelegram;
|
||||
monitorTelegramProvider: typeof import("../../../extensions/telegram/runtime-api.js").monitorTelegramProvider;
|
||||
messageActions: typeof import("../../../extensions/telegram/runtime-api.js").telegramMessageActions;
|
||||
auditGroupMembership: typeof import("../../plugin-sdk/telegram.js").auditTelegramGroupMembership;
|
||||
collectUnmentionedGroupIds: typeof import("../../plugin-sdk/telegram.js").collectTelegramUnmentionedGroupIds;
|
||||
probeTelegram: typeof import("../../plugin-sdk/telegram.js").probeTelegram;
|
||||
resolveTelegramToken: typeof import("../../plugin-sdk/telegram.js").resolveTelegramToken;
|
||||
sendMessageTelegram: typeof import("../../plugin-sdk/telegram.js").sendMessageTelegram;
|
||||
sendPollTelegram: typeof import("../../plugin-sdk/telegram.js").sendPollTelegram;
|
||||
monitorTelegramProvider: typeof import("../../plugin-sdk/telegram.js").monitorTelegramProvider;
|
||||
messageActions: typeof import("../../plugin-sdk/telegram.js").telegramMessageActions;
|
||||
threadBindings: {
|
||||
setIdleTimeoutBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingIdleTimeoutBySessionKey;
|
||||
setMaxAgeBySessionKey: typeof import("../../../extensions/telegram/runtime-api.js").setTelegramThreadBindingMaxAgeBySessionKey;
|
||||
setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/telegram.js").setTelegramThreadBindingIdleTimeoutBySessionKey;
|
||||
setMaxAgeBySessionKey: typeof import("../../plugin-sdk/telegram.js").setTelegramThreadBindingMaxAgeBySessionKey;
|
||||
};
|
||||
typing: {
|
||||
pulse: typeof import("../../../extensions/telegram/runtime-api.js").sendTypingTelegram;
|
||||
pulse: typeof import("../../plugin-sdk/telegram.js").sendTypingTelegram;
|
||||
start: (params: {
|
||||
to: string;
|
||||
accountId?: string;
|
||||
@ -173,8 +173,8 @@ export type PluginRuntimeChannel = {
|
||||
}>;
|
||||
};
|
||||
conversationActions: {
|
||||
editMessage: typeof import("../../../extensions/telegram/runtime-api.js").editMessageTelegram;
|
||||
editReplyMarkup: typeof import("../../../extensions/telegram/runtime-api.js").editMessageReplyMarkupTelegram;
|
||||
editMessage: typeof import("../../plugin-sdk/telegram.js").editMessageTelegram;
|
||||
editReplyMarkup: typeof import("../../plugin-sdk/telegram.js").editMessageReplyMarkupTelegram;
|
||||
clearReplyMarkup: (
|
||||
chatIdInput: string | number,
|
||||
messageIdInput: string | number,
|
||||
@ -187,10 +187,10 @@ export type PluginRuntimeChannel = {
|
||||
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||
},
|
||||
) => Promise<{ ok: true; messageId: string; chatId: string }>;
|
||||
deleteMessage: typeof import("../../../extensions/telegram/runtime-api.js").deleteMessageTelegram;
|
||||
renameTopic: typeof import("../../../extensions/telegram/runtime-api.js").renameForumTopicTelegram;
|
||||
pinMessage: typeof import("../../../extensions/telegram/runtime-api.js").pinMessageTelegram;
|
||||
unpinMessage: typeof import("../../../extensions/telegram/runtime-api.js").unpinMessageTelegram;
|
||||
deleteMessage: typeof import("../../plugin-sdk/telegram.js").deleteMessageTelegram;
|
||||
renameTopic: typeof import("../../plugin-sdk/telegram.js").renameForumTopicTelegram;
|
||||
pinMessage: typeof import("../../plugin-sdk/telegram.js").pinMessageTelegram;
|
||||
unpinMessage: typeof import("../../plugin-sdk/telegram.js").unpinMessageTelegram;
|
||||
};
|
||||
};
|
||||
matrix: {
|
||||
@ -200,15 +200,15 @@ export type PluginRuntimeChannel = {
|
||||
};
|
||||
};
|
||||
signal: {
|
||||
probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal;
|
||||
sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal;
|
||||
monitorSignalProvider: typeof import("../../../extensions/signal/runtime-api.js").monitorSignalProvider;
|
||||
messageActions: typeof import("../../../extensions/signal/runtime-api.js").signalMessageActions;
|
||||
probeSignal: typeof import("../../plugin-sdk/signal.js").probeSignal;
|
||||
sendMessageSignal: typeof import("../../plugin-sdk/signal.js").sendMessageSignal;
|
||||
monitorSignalProvider: typeof import("../../plugin-sdk/signal.js").monitorSignalProvider;
|
||||
messageActions: typeof import("../../plugin-sdk/signal.js").signalMessageActions;
|
||||
};
|
||||
imessage: {
|
||||
monitorIMessageProvider: typeof import("../../../extensions/imessage/runtime-api.js").monitorIMessageProvider;
|
||||
probeIMessage: typeof import("../../../extensions/imessage/runtime-api.js").probeIMessage;
|
||||
sendMessageIMessage: typeof import("../../../extensions/imessage/runtime-api.js").sendMessageIMessage;
|
||||
monitorIMessageProvider: typeof import("../../plugin-sdk/imessage.js").monitorIMessageProvider;
|
||||
probeIMessage: typeof import("../../plugin-sdk/imessage.js").probeIMessage;
|
||||
sendMessageIMessage: typeof import("../../plugin-sdk/imessage.js").sendMessageIMessage;
|
||||
};
|
||||
whatsapp: {
|
||||
getActiveWebListener: typeof import("./runtime-whatsapp-boundary.js").getActiveWebListener;
|
||||
|
||||
@ -3,36 +3,14 @@ import {
|
||||
withBundledPluginEnablementCompat,
|
||||
} from "./bundled-compat.js";
|
||||
import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js";
|
||||
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
|
||||
import {
|
||||
hasExplicitPluginConfig,
|
||||
normalizePluginsConfig,
|
||||
type NormalizedPluginsConfig,
|
||||
} from "./config-state.js";
|
||||
import type { PluginLoadOptions } from "./loader.js";
|
||||
import type { PluginWebSearchProviderEntry } from "./types.js";
|
||||
|
||||
export function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean {
|
||||
const plugins = config?.plugins;
|
||||
if (!plugins) {
|
||||
return false;
|
||||
}
|
||||
if (typeof plugins.enabled === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveBundledWebSearchCompatPluginIds(params: {
|
||||
config?: PluginLoadOptions["config"];
|
||||
workspaceDir?: string;
|
||||
@ -52,7 +30,11 @@ function withBundledWebSearchVitestCompat(params: {
|
||||
}): PluginLoadOptions["config"] {
|
||||
const env = params.env ?? process.env;
|
||||
const isVitest = Boolean(env.VITEST || process.env.VITEST);
|
||||
if (!isVitest || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) {
|
||||
if (
|
||||
!isVitest ||
|
||||
hasExplicitPluginConfig(params.config?.plugins) ||
|
||||
params.pluginIds.length === 0
|
||||
) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ function isErrorPayloadObject(payload: unknown): payload is ErrorPayload {
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseApiErrorPayload(raw: string): ErrorPayload | null {
|
||||
export function parseApiErrorPayload(raw?: string): ErrorPayload | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
27
src/shared/text/auto-linked-file-ref.ts
Normal file
27
src/shared/text/auto-linked-file-ref.ts
Normal file
@ -0,0 +1,27 @@
|
||||
const FILE_REF_EXTENSIONS = ["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"] as const;
|
||||
|
||||
export const FILE_REF_EXTENSIONS_WITH_TLD = new Set<string>(FILE_REF_EXTENSIONS);
|
||||
|
||||
export function isAutoLinkedFileRef(href: string, label: string): boolean {
|
||||
const stripped = href.replace(/^https?:\/\//i, "");
|
||||
if (stripped !== label) {
|
||||
return false;
|
||||
}
|
||||
const dotIndex = label.lastIndexOf(".");
|
||||
if (dotIndex < 1) {
|
||||
return false;
|
||||
}
|
||||
const ext = label.slice(dotIndex + 1).toLowerCase();
|
||||
if (!FILE_REF_EXTENSIONS_WITH_TLD.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
const segments = label.split("/");
|
||||
if (segments.length > 1) {
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
if (segments[i]?.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
9
src/utils/parse-json-compat.ts
Normal file
9
src/utils/parse-json-compat.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import JSON5 from "json5";
|
||||
|
||||
export function parseJsonWithJson5Fallback(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return JSON5.parse(raw);
|
||||
}
|
||||
}
|
||||
@ -29,13 +29,16 @@ describe("plugin extension import boundary inventory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores plugin-sdk boundary shims by scope", async () => {
|
||||
it("ignores boundary shims by scope", async () => {
|
||||
const inventory = await collectPluginExtensionImportBoundaryInventory();
|
||||
|
||||
expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk/"))).toBe(false);
|
||||
expect(inventory.some((entry) => entry.file.startsWith("src/plugin-sdk-internal/"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(inventory.some((entry) => entry.file.startsWith("src/plugins/runtime/runtime-"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("produces stable sorted output", async () => {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
dedupeFilesPreserveOrder,
|
||||
packFilesByDuration,
|
||||
selectMemoryHeavyFiles,
|
||||
selectTimedHeavyFiles,
|
||||
selectUnitHeavyFileGroups,
|
||||
@ -91,3 +93,44 @@ describe("scripts/test-runner-manifest memory selection", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedupeFilesPreserveOrder", () => {
|
||||
it("removes duplicates while keeping the first-seen order", () => {
|
||||
expect(
|
||||
dedupeFilesPreserveOrder([
|
||||
"src/b.test.ts",
|
||||
"src/a.test.ts",
|
||||
"src/b.test.ts",
|
||||
"src/c.test.ts",
|
||||
"src/a.test.ts",
|
||||
]),
|
||||
).toEqual(["src/b.test.ts", "src/a.test.ts", "src/c.test.ts"]);
|
||||
});
|
||||
|
||||
it("filters excluded files before deduping", () => {
|
||||
expect(
|
||||
dedupeFilesPreserveOrder(
|
||||
["src/a.test.ts", "src/b.test.ts", "src/c.test.ts", "src/b.test.ts"],
|
||||
new Set(["src/b.test.ts"]),
|
||||
),
|
||||
).toEqual(["src/a.test.ts", "src/c.test.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("packFilesByDuration", () => {
|
||||
it("packs heavier files into the lightest remaining bucket", () => {
|
||||
const durationByFile = {
|
||||
"src/a.test.ts": 100,
|
||||
"src/b.test.ts": 90,
|
||||
"src/c.test.ts": 20,
|
||||
"src/d.test.ts": 10,
|
||||
} satisfies Record<string, number>;
|
||||
|
||||
expect(
|
||||
packFilesByDuration(Object.keys(durationByFile), 2, (file) => durationByFile[file] ?? 0),
|
||||
).toEqual([
|
||||
["src/a.test.ts", "src/d.test.ts"],
|
||||
["src/b.test.ts", "src/c.test.ts"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -186,6 +186,8 @@ const coreDistEntries = buildCoreDistEntries();
|
||||
function buildUnifiedDistEntries(): Record<string, string> {
|
||||
return {
|
||||
...coreDistEntries,
|
||||
// Internal compat artifact for the root-alias.cjs lazy loader.
|
||||
"plugin-sdk/compat": "src/plugin-sdk/compat.ts",
|
||||
...Object.fromEntries(
|
||||
Object.entries(buildPluginSdkEntrySources()).map(([entry, source]) => [
|
||||
`plugin-sdk/${entry}`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user