mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-21 22:43:15 +08:00
Compare commits
8 Commits
cache/cont
...
pr-60556
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e82896799c | ||
|
|
d59a5e4da8 | ||
|
|
2103737a6a | ||
|
|
4aedf872c0 | ||
|
|
1a2f9f465a | ||
|
|
8bef2d2fed | ||
|
|
399c9ad895 | ||
|
|
0960a0ab95 |
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -35,17 +35,19 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
|
||||
- Related #
|
||||
- [ ] This PR fixes a bug or regression
|
||||
|
||||
## Root Cause (if applicable)
|
||||
## Root Cause / Regression History (if applicable)
|
||||
|
||||
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`.
|
||||
|
||||
- Root cause:
|
||||
- Missing detection / guardrail:
|
||||
- Contributing context (if known):
|
||||
- Prior context (`git blame`, prior PR, issue, or refactor if known):
|
||||
- Why this regressed now:
|
||||
- If unknown, what was ruled out:
|
||||
|
||||
## Regression Test Plan (if applicable)
|
||||
|
||||
For bug fixes or regressions, name the smallest reliable test coverage that should catch this. Otherwise write `N/A`.
|
||||
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write `N/A`.
|
||||
|
||||
- Coverage level that should have caught this:
|
||||
- [ ] Unit test
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -17,7 +17,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests/secrets runtime: restore split secrets suite cache and env isolation cleanup so broader runs do not leak stale plugin or provider snapshot state. (#60395) Thanks @shakkernerd.
|
||||
- Providers/Ollama: add bundled Ollama Web Search provider for key-free web_search via your configured Ollama host and `ollama signin`. (#59318) Thanks @BruceMacD.
|
||||
- Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. (#60544) Thanks @gumadeiras.
|
||||
- Providers/transport: add shared proxy/TLS/auth-aware request transport support across model-provider paths, including Anthropic and Google native transport runtimes, so provider request overrides work beyond OpenAI-family traffic.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -31,7 +30,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/local Bot API: thread `channels.telegram.apiRoot` through buffered reply-media and album downloads so self-hosted Bot API file paths stop falling back to `api.telegram.org` and 404ing. (#59544) Thanks @SARAMALI15792.
|
||||
- Telegram/replies: preserve explicit topic targets when `replyTo` is present while still inheriting the current topic for same-chat replies without an explicit topic. (#59634) Thanks @dashhuang.
|
||||
- Telegram/native commands: clean up metadata-driven progress placeholders when replies fall back, edits fail, or local exec approval prompts are suppressed. (#59300) Thanks @jalehman.
|
||||
- Telegram/models: compare full provider/model refs in the Telegram picker so same-id models from other providers no longer show the wrong current-model checkmark. (#60384) Thanks @sfuminya.
|
||||
- Media/request overrides: resolve shared and capability-filtered media request SecretRefs correctly and expose media transport override fields to schema-driven config consumers. (#59848) Thanks @vincentkoc.
|
||||
- Providers/request overrides: stop advertising unsupported proxy and TLS transport settings on `models.providers.*.request`, and fail closed if unvalidated config tries to route LLM model-provider traffic through dead transport fields. (#59682) Thanks @vincentkoc.
|
||||
- Discord/mentions: treat `@everyone` and `@here` as valid mention-gate triggers in guild preflight so mention-required bots still respond to those broadcasts. (#60343) Thanks @geekhuashan.
|
||||
@@ -40,7 +38,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Ollama/auth: prefer real cloud auth over local marker during model auth resolution so cloud-backed Ollama auth does not get shadowed by stale local-only markers.
|
||||
- Plugins/Kimi Coding: parse tagged Kimi tool-call text into structured tool calls on the provider stream path so tools execute instead of echoing raw markup. (#60051) Thanks @obviyus.
|
||||
- Channels/passive hooks: emit passive message hooks for mention-skipped Telegram and Signal group messages when `ingest` is enabled, including wildcard/default fallback and per-group override handling. (#60018) Thanks @obviyus.
|
||||
- Providers/compat: stop forcing OpenAI-only payload defaults on proxy and custom OpenAI-compatible routes, and preserve native vendor-specific reasoning, tool, and streaming behavior for Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, Z.ai, and other routed provider paths.
|
||||
- Plugins/manifest registry: stop warning when an explicit manifest `id` intentionally differs from the discovery hint. (#59185) Thanks @samzong.
|
||||
- WhatsApp/streaming: honor `channels.whatsapp.blockStreaming` again for inbound auto-replies so progressive block replies can be enabled explicitly instead of being forced to final-only delivery. Thanks @mcaxtr.
|
||||
- Auth/failover: shorten `auth_permanent` lockouts, add dedicated config knobs for permanent-auth backoff, and downgrade ambiguous auth-ish upstream incidents to retryable auth failures so providers recover automatically after transient outages. (#60404) Thanks @extrasmall0.
|
||||
@@ -48,7 +45,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/runtime: reuse compatible active registries for `web_search` and `web_fetch` provider snapshot resolution so repeated runtime reads do not re-import the same bundled plugin set on each agent message. Related #48380.
|
||||
- Infra/tailscale: ignore `OPENCLAW_TEST_TAILSCALE_BINARY` outside explicit test environments and block it from workspace `.env`, so test-only binary overrides cannot be injected through trusted repository state. (#58468) Thanks @eleqtrizit.
|
||||
- Plugins/OpenAI: enable reference-image edits for `gpt-image-1` by routing edit calls to `/images/edits` with multipart image uploads, and update image-generation capability/docs metadata accordingly. Thanks @steipete.
|
||||
- Cache/context guard: compact newest tool results first so the cached prompt prefix stays byte-identical and avoids full re-tokenization every turn past the 75% context threshold. (#58036) Thanks @bcherny.
|
||||
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
|
||||
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
|
||||
- Plugins/startup: migrate legacy `tools.web.search.<provider>` config before strict startup validation, and record plugin failure phase/timestamp so degraded plugin startup is easier to diagnose from logs and `plugins list`.
|
||||
@@ -98,10 +94,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/workspace: respect `agents.defaults.workspace` for non-default agents by resolving them under the configured base path instead of falling back to `workspace-<id>`. (#59858) Thanks @joelnishanth.
|
||||
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the snapshot during redaction. (#28214) thanks @solodmd.
|
||||
- Plugins/runtime: honor explicit capability allowlists during fallback speech, media-understanding, and image-generation provider loading so bundled capability plugins do not bypass restrictive `plugins.allow` config. (#52262) Thanks @PerfectPan.
|
||||
- Hooks/tool policy: block tool calls when a `before_tool_call` hook crashes so hook failures fail closed instead of silently allowing execution. (#59822) Thanks @pgondhi987.
|
||||
- Matrix/media: surface a dedicated `[matrix <kind> attachment too large]` marker for oversized inbound media instead of the generic unavailable marker, and classify size-limit failures with a typed Matrix error. (#60289) Thanks @efe-arv.
|
||||
- WhatsApp/watchdog: reset watchdog timeout after reconnect so quiet channels no longer enter a tight reconnect loop from stale message timestamps carried across connection runs. (#60007) Thanks @MonkeyLeeT.
|
||||
- Agents/fallback: persist selected fallback overrides before retry attempts start, prefer persisted overrides during live-session reconciliation, and keep provider-scoped auth-profile failover from snapping retries back to stale primary selections.
|
||||
- Plugins/marketplace: block remote marketplace symlink escapes without rewriting ordinary local marketplace install paths. (#60556) Thanks @eleqtrizit.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
@@ -131,10 +124,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sandbox/security: block credential-path binds even when sandbox home paths resolve through canonical aliases, so agent containers cannot mount user secret stores through alternate home-directory paths. (#59157) Thanks @eleqtrizit.
|
||||
|
||||
## 2026.4.1-beta.1
|
||||
|
||||
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
|
||||
- Providers/OpenRouter: gate documented OpenRouter attribution to native OpenRouter endpoints or the default route so custom proxy base URLs do not inherit OpenRouter request headers.
|
||||
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "fb90e7b1977f43661ac91681d16da11f9ddd85630407ef170eaada0a6ee39972",
|
||||
"originHash" : "1c9c9d251b760ed3234ecff741a88eb4bf42315ad6f50ac7392b187cf226c16c",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -24,7 +24,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -3,7 +3,6 @@ summary: "How OpenClaw rotates auth profiles and falls back across models"
|
||||
read_when:
|
||||
- Diagnosing auth profile rotation, cooldowns, or model fallback behavior
|
||||
- Updating failover rules for auth profiles or models
|
||||
- Understanding how session model overrides interact with fallback retries
|
||||
title: "Model Failover"
|
||||
---
|
||||
|
||||
@@ -16,44 +15,6 @@ OpenClaw handles failures in two stages:
|
||||
|
||||
This doc explains the runtime rules and the data that backs them.
|
||||
|
||||
## Runtime flow
|
||||
|
||||
For a normal text run, OpenClaw evaluates candidates in this order:
|
||||
|
||||
1. The currently selected session model.
|
||||
2. Configured `agents.defaults.model.fallbacks` in order.
|
||||
3. The configured primary model at the end when the run started from an override.
|
||||
|
||||
Inside each candidate, OpenClaw tries auth-profile failover before advancing to
|
||||
the next model candidate.
|
||||
|
||||
High-level sequence:
|
||||
|
||||
1. Resolve the active session model and auth-profile preference.
|
||||
2. Build the model candidate chain.
|
||||
3. Try the current provider with auth-profile rotation/cooldown rules.
|
||||
4. If that provider is exhausted with a failover-worthy error, move to the next
|
||||
model candidate.
|
||||
5. Persist the selected fallback override before the retry starts so other
|
||||
session readers see the same provider/model the runner is about to use.
|
||||
6. If the fallback candidate fails, roll back only the fallback-owned session
|
||||
override fields when they still match that failed candidate.
|
||||
7. If every candidate fails, throw a `FallbackSummaryError` with per-attempt
|
||||
detail and the soonest cooldown expiry when one is known.
|
||||
|
||||
This is intentionally narrower than "save and restore the whole session". The
|
||||
reply runner only persists the model-selection fields it owns for fallback:
|
||||
|
||||
- `providerOverride`
|
||||
- `modelOverride`
|
||||
- `authProfileOverride`
|
||||
- `authProfileOverrideSource`
|
||||
- `authProfileOverrideCompactionCount`
|
||||
|
||||
That prevents a failed fallback retry from overwriting newer unrelated session
|
||||
mutations such as manual `/model` changes or session rotation updates that
|
||||
happened while the attempt was running.
|
||||
|
||||
## Auth storage (keys + OAuth)
|
||||
|
||||
OpenClaw uses **auth profiles** for both API keys and OAuth tokens.
|
||||
@@ -187,102 +148,6 @@ with `auth.cooldowns.overloadedProfileRotations`,
|
||||
When a run starts with a model override (hooks or CLI), fallbacks still end at
|
||||
`agents.defaults.model.primary` after trying any configured fallbacks.
|
||||
|
||||
### Candidate chain rules
|
||||
|
||||
OpenClaw builds the candidate list from the currently requested `provider/model`
|
||||
plus configured fallbacks.
|
||||
|
||||
Rules:
|
||||
|
||||
- The requested model is always first.
|
||||
- Explicit configured fallbacks are deduplicated but not filtered by the model
|
||||
allowlist. They are treated as explicit operator intent.
|
||||
- If the current run is already on a configured fallback in the same provider
|
||||
family, OpenClaw keeps using the full configured chain.
|
||||
- If the current run is on a different provider than config and that current
|
||||
model is not already part of the configured fallback chain, OpenClaw does not
|
||||
append unrelated configured fallbacks from another provider.
|
||||
- When the run started from an override, the configured primary is appended at
|
||||
the end so the chain can settle back onto the normal default once earlier
|
||||
candidates are exhausted.
|
||||
|
||||
### Which errors advance fallback
|
||||
|
||||
Model fallback continues on:
|
||||
|
||||
- auth failures
|
||||
- rate limits and cooldown exhaustion
|
||||
- overloaded/provider-busy errors
|
||||
- timeout-shaped failover errors
|
||||
- billing disables
|
||||
- `LiveSessionModelSwitchError`, which is normalized into a failover path so a
|
||||
stale persisted model does not create an outer retry loop
|
||||
- other unrecognized errors when there are still remaining candidates
|
||||
|
||||
Model fallback does not continue on:
|
||||
|
||||
- explicit aborts that are not timeout/failover-shaped
|
||||
- context overflow errors that should stay inside compaction/retry logic
|
||||
- a final unknown error when there are no candidates left
|
||||
|
||||
### Cooldown skip vs probe behavior
|
||||
|
||||
When every auth profile for a provider is already in cooldown, OpenClaw does
|
||||
not automatically skip that provider forever. It makes a per-candidate decision:
|
||||
|
||||
- Persistent auth failures skip the whole provider immediately.
|
||||
- Billing disables usually skip, but the primary candidate can still be probed
|
||||
on a throttle so recovery is possible without restarting.
|
||||
- The primary candidate may be probed near cooldown expiry, with a per-provider
|
||||
throttle.
|
||||
- Same-provider fallback siblings can be attempted despite cooldown when the
|
||||
failure looks transient (`rate_limit`, `overloaded`, or unknown).
|
||||
- Transient cooldown probes are limited to one per provider per fallback run so
|
||||
a single provider does not stall cross-provider fallback.
|
||||
|
||||
## Session overrides and live model switching
|
||||
|
||||
Session model changes are shared state. The active runner, `/model` command,
|
||||
compaction/session updates, and live-session reconciliation all read or write
|
||||
parts of the same session entry.
|
||||
|
||||
That means fallback retries have to coordinate with live model switching:
|
||||
|
||||
- Before a fallback retry starts, the reply runner persists the selected
|
||||
fallback override fields to the session entry.
|
||||
- Live-session reconciliation prefers persisted session overrides over stale
|
||||
runtime model fields.
|
||||
- If the fallback attempt fails, the runner rolls back only the override fields
|
||||
it wrote, and only if they still match that failed candidate.
|
||||
|
||||
This prevents the classic race:
|
||||
|
||||
1. Primary fails.
|
||||
2. Fallback candidate is chosen in memory.
|
||||
3. Session store still says the old primary.
|
||||
4. Live-session reconciliation reads the stale session state.
|
||||
5. The retry gets snapped back to the old model before the fallback attempt
|
||||
starts.
|
||||
|
||||
The persisted fallback override closes that window, and the narrow rollback
|
||||
keeps newer manual or runtime session changes intact.
|
||||
|
||||
## Observability and failure summaries
|
||||
|
||||
`runWithModelFallback(...)` records per-attempt details that feed logs and
|
||||
user-facing cooldown messaging:
|
||||
|
||||
- provider/model attempted
|
||||
- reason (`rate_limit`, `overloaded`, `billing`, `auth`, `model_not_found`, and
|
||||
similar failover reasons)
|
||||
- optional status/code
|
||||
- human-readable error summary
|
||||
|
||||
When every candidate fails, OpenClaw throws `FallbackSummaryError`. The outer
|
||||
reply runner can use that to build a more specific message such as "all models
|
||||
are temporarily rate-limited" and include the soonest cooldown expiry when one
|
||||
is known.
|
||||
|
||||
## Related config
|
||||
|
||||
See [Gateway configuration](/gateway/configuration) for:
|
||||
|
||||
@@ -16,8 +16,6 @@ For model selection rules, see [/concepts/models](/concepts/models).
|
||||
- Model refs use `provider/model` (example: `opencode/claude-opus-4-6`).
|
||||
- If you set `agents.defaults.models`, it becomes the allowlist.
|
||||
- CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set <provider/model>`.
|
||||
- Fallback runtime rules, cooldown probes, and session-override persistence are
|
||||
documented in [/concepts/model-failover](/concepts/model-failover).
|
||||
- Provider plugins can inject model catalogs via `registerProvider({ catalog })`;
|
||||
OpenClaw merges that output into `models.providers` before writing
|
||||
`models.json`.
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
createAnthropicServiceTierWrapper,
|
||||
resolveAnthropicBetas,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicServiceTier,
|
||||
} from "./stream-wrappers.js";
|
||||
@@ -21,8 +21,8 @@ export function setBlueBubblesDmPolicy(
|
||||
const existingAllowFrom =
|
||||
resolvedAccountId === "default"
|
||||
? cfg.channels?.bluebubbles?.allowFrom
|
||||
: cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]?.allowFrom ??
|
||||
cfg.channels?.bluebubbles?.allowFrom;
|
||||
: (cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]?.allowFrom ??
|
||||
cfg.channels?.bluebubbles?.allowFrom);
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
|
||||
@@ -149,14 +149,9 @@ const dmPolicy: ChannelSetupDmPolicy = {
|
||||
resolveBlueBubblesAccount({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultBlueBubblesAccountId(cfg),
|
||||
}).config
|
||||
.dmPolicy ?? "pairing",
|
||||
}).config.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy, accountId) =>
|
||||
setBlueBubblesDmPolicy(
|
||||
cfg,
|
||||
accountId ?? resolveDefaultBlueBubblesAccountId(cfg),
|
||||
policy,
|
||||
),
|
||||
setBlueBubblesDmPolicy(cfg, accountId ?? resolveDefaultBlueBubblesAccountId(cfg), policy),
|
||||
promptAllowFrom: promptBlueBubblesAllowFrom,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export { createThreadBindingManager } from "./src/monitor/thread-bindings.manager.js";
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-config-contract.js";
|
||||
export {
|
||||
unsupportedSecretRefSurfacePatterns,
|
||||
collectUnsupportedSecretRefConfigCandidates,
|
||||
} from "./src/security-contract.js";
|
||||
export { deriveLegacySessionChatType } from "./src/session-contract.js";
|
||||
export type {
|
||||
DiscordInteractiveHandlerContext,
|
||||
DiscordInteractiveHandlerRegistration,
|
||||
} from "./src/interactive-dispatch.js";
|
||||
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";
|
||||
@@ -108,7 +108,8 @@ export async function handleDiscordGuildAction(
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const effectiveAccountId = accountId ?? (cfg ? resolveDefaultDiscordAccountId(cfg) : undefined);
|
||||
const effectiveAccountId =
|
||||
accountId ?? (cfg ? resolveDefaultDiscordAccountId(cfg) : undefined);
|
||||
const member = effectiveAccountId
|
||||
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, {
|
||||
accountId: effectiveAccountId,
|
||||
|
||||
@@ -499,7 +499,9 @@ describe("handleDiscordGuildAction", () => {
|
||||
client_status: {},
|
||||
} as never);
|
||||
|
||||
discordGuildActionRuntime.fetchMemberInfoDiscord = vi.fn(async () => ({ user: { id: "U1" } })) as never;
|
||||
discordGuildActionRuntime.fetchMemberInfoDiscord = vi.fn(async () => ({
|
||||
user: { id: "U1" },
|
||||
})) as never;
|
||||
|
||||
const result = await handleDiscordGuildAction(
|
||||
"memberInfo",
|
||||
|
||||
@@ -90,9 +90,7 @@ describe("discordMessageActions", () => {
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(defaultDiscovery?.actions).toEqual(
|
||||
expect.arrayContaining(["send", "poll"]),
|
||||
);
|
||||
expect(defaultDiscovery?.actions).toEqual(expect.arrayContaining(["send", "poll"]));
|
||||
expect(defaultDiscovery?.actions).not.toContain("react");
|
||||
expect(workDiscovery?.actions).toEqual(
|
||||
expect.arrayContaining(["send", "react", "reactions", "emoji-list"]),
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeDiscordStreamingAliases(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
const hadLegacyStreamMode = updated.streamMode !== undefined;
|
||||
const beforeStreaming = updated.streaming;
|
||||
const resolved = resolveDiscordPreviewStreamMode(updated);
|
||||
const shouldNormalize =
|
||||
hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
|
||||
if (!shouldNormalize) {
|
||||
return { entry: updated, changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if (beforeStreaming !== resolved) {
|
||||
updated = { ...updated, streaming: resolved };
|
||||
changed = true;
|
||||
}
|
||||
if (hadLegacyStreamMode) {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
|
||||
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
|
||||
params.changes.push(
|
||||
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
params.pathPrefix.startsWith("channels.discord") &&
|
||||
resolved === "off" &&
|
||||
hadLegacyStreamMode
|
||||
) {
|
||||
params.changes.push(
|
||||
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
|
||||
);
|
||||
}
|
||||
return { entry: updated, changed };
|
||||
}
|
||||
|
||||
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
|
||||
const entry = asObjectRecord(value);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" &&
|
||||
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
|
||||
);
|
||||
}
|
||||
|
||||
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
|
||||
const accounts = asObjectRecord(value);
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
|
||||
}
|
||||
|
||||
const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const;
|
||||
|
||||
function hasLegacyTtsProviderKeys(value: unknown): boolean {
|
||||
const tts = asObjectRecord(value);
|
||||
if (!tts) {
|
||||
return false;
|
||||
}
|
||||
return LEGACY_TTS_PROVIDER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(tts, key));
|
||||
}
|
||||
|
||||
function hasLegacyDiscordAccountTtsProviderKeys(value: unknown): boolean {
|
||||
const accounts = asObjectRecord(value);
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((accountValue) => {
|
||||
const account = asObjectRecord(accountValue);
|
||||
const voice = asObjectRecord(account?.voice);
|
||||
return hasLegacyTtsProviderKeys(voice?.tts);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeMissing(target: Record<string, unknown>, source: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
const existing = target[key];
|
||||
if (existing === undefined) {
|
||||
target[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
existing &&
|
||||
typeof existing === "object" &&
|
||||
!Array.isArray(existing) &&
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
mergeMissing(existing as Record<string, unknown>, value as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateTtsProviders(tts: Record<string, unknown>): Record<string, unknown> {
|
||||
const providers = asObjectRecord(tts.providers) ?? {};
|
||||
tts.providers = providers;
|
||||
return providers;
|
||||
}
|
||||
|
||||
function mergeLegacyTtsProviderConfig(
|
||||
tts: Record<string, unknown>,
|
||||
legacyKey: string,
|
||||
providerId: string,
|
||||
): boolean {
|
||||
const legacyValue = asObjectRecord(tts[legacyKey]);
|
||||
if (!legacyValue) {
|
||||
return false;
|
||||
}
|
||||
const providers = getOrCreateTtsProviders(tts);
|
||||
const existing = asObjectRecord(providers[providerId]) ?? {};
|
||||
const merged = structuredClone(existing);
|
||||
mergeMissing(merged, legacyValue);
|
||||
providers[providerId] = merged;
|
||||
delete tts[legacyKey];
|
||||
return true;
|
||||
}
|
||||
|
||||
function migrateLegacyTtsConfig(
|
||||
tts: Record<string, unknown> | null,
|
||||
pathLabel: string,
|
||||
changes: string[],
|
||||
): boolean {
|
||||
if (!tts) {
|
||||
return false;
|
||||
}
|
||||
let changed = false;
|
||||
if (mergeLegacyTtsProviderConfig(tts, "openai", "openai")) {
|
||||
changes.push(`Moved ${pathLabel}.openai → ${pathLabel}.providers.openai.`);
|
||||
changed = true;
|
||||
}
|
||||
if (mergeLegacyTtsProviderConfig(tts, "elevenlabs", "elevenlabs")) {
|
||||
changes.push(`Moved ${pathLabel}.elevenlabs → ${pathLabel}.providers.elevenlabs.`);
|
||||
changed = true;
|
||||
}
|
||||
if (mergeLegacyTtsProviderConfig(tts, "microsoft", "microsoft")) {
|
||||
changes.push(`Moved ${pathLabel}.microsoft → ${pathLabel}.providers.microsoft.`);
|
||||
changed = true;
|
||||
}
|
||||
if (mergeLegacyTtsProviderConfig(tts, "edge", "microsoft")) {
|
||||
changes.push(`Moved ${pathLabel}.edge → ${pathLabel}.providers.microsoft.`);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "discord"],
|
||||
message:
|
||||
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
|
||||
match: hasLegacyDiscordStreamingAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord", "accounts"],
|
||||
message:
|
||||
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
|
||||
match: hasLegacyDiscordAccountStreamingAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord", "voice", "tts"],
|
||||
message:
|
||||
"channels.discord.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.voice.tts.providers.<provider> (auto-migrated on load).",
|
||||
match: hasLegacyTtsProviderKeys,
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord", "accounts"],
|
||||
message:
|
||||
"channels.discord.accounts.<id>.voice.tts.<provider> keys (openai/elevenlabs/microsoft/edge) are legacy; use channels.discord.accounts.<id>.voice.tts.providers.<provider> (auto-migrated on load).",
|
||||
match: hasLegacyDiscordAccountTtsProviderKeys,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.discord);
|
||||
if (!rawEntry) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updated = rawEntry;
|
||||
let changed = false;
|
||||
|
||||
const streaming = normalizeDiscordStreamingAliases({
|
||||
entry: updated,
|
||||
pathPrefix: "channels.discord",
|
||||
changes,
|
||||
});
|
||||
updated = streaming.entry;
|
||||
changed = changed || streaming.changed;
|
||||
|
||||
const rawAccounts = asObjectRecord(updated.accounts);
|
||||
if (rawAccounts) {
|
||||
let accountsChanged = false;
|
||||
const accounts = { ...rawAccounts };
|
||||
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
|
||||
const account = asObjectRecord(rawAccount);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const accountStreaming = normalizeDiscordStreamingAliases({
|
||||
entry: account,
|
||||
pathPrefix: `channels.discord.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
if (accountStreaming.changed) {
|
||||
accounts[accountId] = accountStreaming.entry;
|
||||
accountsChanged = true;
|
||||
}
|
||||
const accountVoice = asObjectRecord(accountStreaming.entry.voice);
|
||||
if (
|
||||
accountVoice &&
|
||||
migrateLegacyTtsConfig(
|
||||
asObjectRecord(accountVoice.tts),
|
||||
`channels.discord.accounts.${accountId}.voice.tts`,
|
||||
changes,
|
||||
)
|
||||
) {
|
||||
accounts[accountId] = {
|
||||
...accountStreaming.entry,
|
||||
voice: accountVoice,
|
||||
};
|
||||
accountsChanged = true;
|
||||
}
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updated = { ...updated, accounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const voice = asObjectRecord(updated.voice);
|
||||
if (
|
||||
voice &&
|
||||
migrateLegacyTtsConfig(asObjectRecord(voice.tts), "channels.discord.voice.tts", changes)
|
||||
) {
|
||||
updated = { ...updated, voice };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
discord: updated,
|
||||
} as OpenClawConfig["channels"],
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import {
|
||||
type ChannelDoctorAdapter,
|
||||
type ChannelDoctorConfigMutation,
|
||||
type ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime";
|
||||
import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js";
|
||||
import { isDiscordMutableAllowEntry } from "./security-audit.js";
|
||||
import {
|
||||
resolveDiscordPreviewStreamMode,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
collectProviderDangerousNameMatchingScopes,
|
||||
isDiscordMutableAllowEntry,
|
||||
} from "openclaw/plugin-sdk/runtime";
|
||||
|
||||
type DiscordNumericIdHit = { path: string; entry: number; safe: boolean };
|
||||
|
||||
@@ -514,48 +517,11 @@ function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
|
||||
const entry = asObjectRecord(value);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" &&
|
||||
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
|
||||
);
|
||||
}
|
||||
|
||||
function hasLegacyDiscordAccountStreamingAliases(value: unknown): boolean {
|
||||
const accounts = asObjectRecord(value);
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) => hasLegacyDiscordStreamingAliases(account));
|
||||
}
|
||||
|
||||
const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "discord"],
|
||||
message:
|
||||
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
|
||||
match: hasLegacyDiscordStreamingAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord", "accounts"],
|
||||
message:
|
||||
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
|
||||
match: hasLegacyDiscordAccountStreamingAliases,
|
||||
},
|
||||
];
|
||||
|
||||
export const discordDoctor: ChannelDoctorAdapter = {
|
||||
dmAllowFromMode: "topOrNested",
|
||||
groupModel: "route",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
legacyConfigRules: DISCORD_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg),
|
||||
collectPreviewWarnings: ({ cfg, doctorFixCommand }) =>
|
||||
collectDiscordNumericIdWarnings({
|
||||
|
||||
@@ -80,10 +80,7 @@ function resolveDiscordPolicyContext(params: ChannelGroupContext) {
|
||||
(params.accountId
|
||||
? params.cfg.channels?.discord?.accounts?.[params.accountId]?.guilds
|
||||
: undefined) ?? params.cfg.channels?.discord?.guilds;
|
||||
const guildEntry = resolveDiscordGuildEntry(
|
||||
guilds,
|
||||
params.groupSpace,
|
||||
);
|
||||
const guildEntry = resolveDiscordGuildEntry(guilds, params.groupSpace);
|
||||
const channelEntries = guildEntry?.channels;
|
||||
const channelEntry =
|
||||
channelEntries && Object.keys(channelEntries).length > 0
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { logDebug } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDefaultDiscordAccountId } from "../accounts.js";
|
||||
import {
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
normalizeDiscordSlug,
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
} from "./allow-list.js";
|
||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
|
||||
import { resolveDefaultDiscordAccountId } from "../accounts.js";
|
||||
import {
|
||||
formatDiscordUserTag,
|
||||
resolveDiscordSystemLocation,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pi
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
@@ -39,7 +40,6 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
|
||||
import { createDiscordDraftStream } from "../draft-stream.js";
|
||||
import { resolveDiscordPreviewStreamMode } from "../preview-streaming.js";
|
||||
import { removeReactionDiscord } from "../send.js";
|
||||
import { editMessageDiscord } from "../send.messages.js";
|
||||
import {
|
||||
|
||||
@@ -11,34 +11,22 @@ import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtim
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type ConversationRuntimeModule = typeof import("openclaw/plugin-sdk/conversation-runtime");
|
||||
type ResolveConfiguredBindingRoute = ConversationRuntimeModule["resolveConfiguredBindingRoute"];
|
||||
type ConfiguredBindingRouteResult = ReturnType<ResolveConfiguredBindingRoute>;
|
||||
type EnsureConfiguredBindingRouteReady =
|
||||
ConversationRuntimeModule["ensureConfiguredBindingRouteReady"];
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
type ResolveConfiguredBindingRoute =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
|
||||
function createUnboundConfiguredRouteResult(): ConfiguredBindingRouteResult {
|
||||
return {
|
||||
bindingResolution: null,
|
||||
route: {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
sessionKey: SESSION_KEY,
|
||||
mainSessionKey: SESSION_KEY,
|
||||
lastRoutePolicy: "main",
|
||||
matchedBy: "default",
|
||||
},
|
||||
};
|
||||
}
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
|
||||
vi.fn<EnsureConfiguredBindingRouteReady>(async () => ({ ok: true })),
|
||||
);
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
|
||||
vi.fn<ResolveConfiguredBindingRoute>(() => createUnboundConfiguredRouteResult()),
|
||||
vi.fn<ResolveConfiguredBindingRoute>(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
})),
|
||||
);
|
||||
|
||||
type ConfiguredBindingRoute = ConfiguredBindingRouteResult;
|
||||
type ConfiguredBindingRoute = ReturnType<ResolveConfiguredBindingRoute>;
|
||||
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRoute["bindingResolution"]>;
|
||||
|
||||
function createConfiguredRouteResult(
|
||||
@@ -47,11 +35,6 @@ function createConfiguredRouteResult(
|
||||
return {
|
||||
bindingResolution: {
|
||||
record: {
|
||||
bindingId: "binding-1",
|
||||
targetSessionKey: SESSION_KEY,
|
||||
targetKind: "session",
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
@@ -104,7 +87,10 @@ describe("discord native /think autocomplete", () => {
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
resolveConfiguredBindingRouteMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue(createUnboundConfiguredRouteResult());
|
||||
resolveConfiguredBindingRouteMock.mockImplementation(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
}));
|
||||
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
STORE_PATH,
|
||||
|
||||
@@ -1063,14 +1063,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
eventQueueListenerTimeoutMs: eventQueueOpts.listenerTimeout,
|
||||
});
|
||||
|
||||
logDiscordStartupPhase({
|
||||
runtime,
|
||||
accountId: account.accountId,
|
||||
phase: "client-start",
|
||||
startAt: startupStartedAt,
|
||||
gateway: lifecycleGateway,
|
||||
});
|
||||
|
||||
const botIdentity =
|
||||
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
|
||||
runtime.log?.(
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
|
||||
|
||||
function normalizeStreamingMode(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function parseStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null {
|
||||
const normalized = normalizeStreamingMode(value);
|
||||
if (
|
||||
normalized === "off" ||
|
||||
normalized === "partial" ||
|
||||
normalized === "block" ||
|
||||
normalized === "progress"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDiscordPreviewStreamMode(value: unknown): DiscordPreviewStreamMode | null {
|
||||
const parsed = parseStreamingMode(value);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return parsed === "progress" ? "partial" : parsed;
|
||||
}
|
||||
|
||||
export function resolveDiscordPreviewStreamMode(
|
||||
params: {
|
||||
streamMode?: unknown;
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): DiscordPreviewStreamMode {
|
||||
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
|
||||
if (parsedStreaming) {
|
||||
return parsedStreaming;
|
||||
}
|
||||
|
||||
const legacy = parseDiscordPreviewStreamMode(params.streamMode);
|
||||
if (legacy) {
|
||||
return legacy;
|
||||
}
|
||||
if (typeof params.streaming === "boolean") {
|
||||
return params.streaming ? "partial" : "off";
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import {
|
||||
collectNestedChannelFieldAssignments,
|
||||
collectNestedChannelTtsAssignments,
|
||||
collectSimpleChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
isBaseFieldActiveForChannelSurface,
|
||||
isEnabledFlag,
|
||||
isRecord,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.discord.accounts.*.pluralkit.token",
|
||||
targetType: "channels.discord.accounts.*.pluralkit.token",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.accounts.*.pluralkit.token",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.accounts.*.token",
|
||||
targetType: "channels.discord.accounts.*.token",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.accounts.*.token",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
|
||||
targetType: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.accounts.*.voice.tts.providers.*.apiKey",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
providerIdPathSegmentIndex: 6,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.pluralkit.token",
|
||||
targetType: "channels.discord.pluralkit.token",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.pluralkit.token",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.token",
|
||||
targetType: "channels.discord.token",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.token",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.discord.voice.tts.providers.*.apiKey",
|
||||
targetType: "channels.discord.voice.tts.providers.*.apiKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.discord.voice.tts.providers.*.apiKey",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
providerIdPathSegmentIndex: 4,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "discord");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { channel: discord, surface } = resolved;
|
||||
collectSimpleChannelFieldAssignments({
|
||||
channelKey: "discord",
|
||||
field: "token",
|
||||
channel: discord,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topInactiveReason: "no enabled account inherits this top-level Discord token.",
|
||||
accountInactiveReason: "Discord account is disabled.",
|
||||
});
|
||||
collectNestedChannelFieldAssignments({
|
||||
channelKey: "discord",
|
||||
nestedKey: "pluralkit",
|
||||
field: "token",
|
||||
channel: discord,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActive:
|
||||
isBaseFieldActiveForChannelSurface(surface, "pluralkit") &&
|
||||
isRecord(discord.pluralkit) &&
|
||||
isEnabledFlag(discord.pluralkit),
|
||||
topInactiveReason:
|
||||
"no enabled Discord surface inherits this top-level PluralKit config or PluralKit is disabled.",
|
||||
accountActive: ({ account, enabled }) =>
|
||||
enabled && isRecord(account.pluralkit) && isEnabledFlag(account.pluralkit),
|
||||
accountInactiveReason: "Discord account is disabled or PluralKit is disabled for this account.",
|
||||
});
|
||||
collectNestedChannelTtsAssignments({
|
||||
channelKey: "discord",
|
||||
nestedKey: "voice",
|
||||
channel: discord,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActive:
|
||||
isBaseFieldActiveForChannelSurface(surface, "voice") &&
|
||||
isRecord(discord.voice) &&
|
||||
isEnabledFlag(discord.voice),
|
||||
topInactiveReason:
|
||||
"no enabled Discord surface inherits this top-level voice config or voice is disabled.",
|
||||
accountActive: ({ account, enabled }) =>
|
||||
enabled && isRecord(account.voice) && isEnabledFlag(account.voice),
|
||||
accountInactiveReason: "Discord account is disabled or voice is disabled for this account.",
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,7 @@ function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isDiscordMutableAllowEntry(raw: string): boolean {
|
||||
function isDiscordMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
type UnsupportedSecretRefConfigCandidate = {
|
||||
path: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export const unsupportedSecretRefSurfacePatterns = [
|
||||
"channels.discord.threadBindings.webhookToken",
|
||||
"channels.discord.accounts.*.threadBindings.webhookToken",
|
||||
] as const;
|
||||
|
||||
export function collectUnsupportedSecretRefConfigCandidates(
|
||||
raw: unknown,
|
||||
): UnsupportedSecretRefConfigCandidate[] {
|
||||
if (!isRecord(raw)) {
|
||||
return [];
|
||||
}
|
||||
if (!isRecord(raw.channels) || !isRecord(raw.channels.discord)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: UnsupportedSecretRefConfigCandidate[] = [];
|
||||
const discord = raw.channels.discord;
|
||||
const threadBindings = isRecord(discord.threadBindings) ? discord.threadBindings : null;
|
||||
if (threadBindings) {
|
||||
candidates.push({
|
||||
path: "channels.discord.threadBindings.webhookToken",
|
||||
value: threadBindings.webhookToken,
|
||||
});
|
||||
}
|
||||
|
||||
const accounts = isRecord(discord.accounts) ? discord.accounts : null;
|
||||
if (!accounts) {
|
||||
return candidates;
|
||||
}
|
||||
for (const [accountId, account] of Object.entries(accounts)) {
|
||||
if (!isRecord(account) || !isRecord(account.threadBindings)) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
path: `channels.discord.accounts.${accountId}.threadBindings.webhookToken`,
|
||||
value: account.threadBindings.webhookToken,
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function deriveLegacySessionChatType(sessionKey: string): "channel" | undefined {
|
||||
return /^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(sessionKey) ? "channel" : undefined;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { createFeishuThreadBindingManager } from "./src/thread-bindings.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
export { messageActionTargetAliases } from "./src/message-action-contract.js";
|
||||
@@ -1,48 +1,25 @@
|
||||
// Private runtime barrel for the bundled Feishu extension.
|
||||
// Keep this barrel thin and generic-only.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export type {
|
||||
AllowlistMatch,
|
||||
AnyAgentTool,
|
||||
BaseProbeResult,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelMeta,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
HistoryEntry,
|
||||
OpenClawConfig as ClawdbotConfig,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
OutboundIdentity,
|
||||
PluginRuntime,
|
||||
ReplyPayload,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/feishu";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
createActionGate,
|
||||
createDedupeCache,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
buildChannelConfigSchema,
|
||||
buildProbeChannelStatusSummary,
|
||||
createActionGate,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export {
|
||||
evaluateSupplementalContextVisibility,
|
||||
filterSupplementalContextItems,
|
||||
resolveChannelContextVisibilityMode,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
|
||||
export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
export { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
} from "openclaw/plugin-sdk/feishu";
|
||||
export * from "openclaw/plugin-sdk/feishu";
|
||||
export {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
|
||||
@@ -51,7 +51,6 @@ import type {
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
buildFeishuModelOverrideParentCandidates,
|
||||
buildFeishuConversationId,
|
||||
parseFeishuConversationId,
|
||||
parseFeishuDirectConversationId,
|
||||
@@ -60,7 +59,6 @@ import {
|
||||
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
|
||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { collectFeishuSecurityAuditFindings } from "./security-audit.js";
|
||||
import {
|
||||
resolveFeishuParentConversationCandidates,
|
||||
resolveFeishuSessionConversation,
|
||||
@@ -571,8 +569,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
},
|
||||
conversationBindings: {
|
||||
defaultTopLevelPlacement: "current",
|
||||
buildModelOverrideParentCandidates: ({ parentConversationId }) =>
|
||||
buildFeishuModelOverrideParentCandidates(parentConversationId),
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
|
||||
@@ -1186,7 +1182,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}>(collectFeishuSecurityWarnings),
|
||||
collectAuditFindings: ({ cfg }) => collectFeishuSecurityAuditFindings({ cfg }),
|
||||
},
|
||||
pairing: {
|
||||
text: {
|
||||
|
||||
@@ -166,32 +166,3 @@ export function parseFeishuConversationId(params: {
|
||||
scope: "group",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFeishuModelOverrideParentCandidates(
|
||||
parentConversationId?: string | null,
|
||||
): string[] {
|
||||
const rawId = normalizeText(parentConversationId);
|
||||
if (!rawId) {
|
||||
return [];
|
||||
}
|
||||
const topicSenderMatch = rawId.match(/^(.+):topic:([^:]+):sender:([^:]+)$/i);
|
||||
if (topicSenderMatch) {
|
||||
const chatId = topicSenderMatch[1]?.trim().toLowerCase();
|
||||
const topicId = topicSenderMatch[2]?.trim().toLowerCase();
|
||||
if (chatId && topicId) {
|
||||
return [`${chatId}:topic:${topicId}`, chatId];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const topicMatch = rawId.match(/^(.+):topic:([^:]+)$/i);
|
||||
if (topicMatch) {
|
||||
const chatId = topicMatch[1]?.trim().toLowerCase();
|
||||
return chatId ? [chatId] : [];
|
||||
}
|
||||
const senderMatch = rawId.match(/^(.+):sender:([^:]+)$/i);
|
||||
if (senderMatch) {
|
||||
const chatId = senderMatch[1]?.trim().toLowerCase();
|
||||
return chatId ? [chatId] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
|
||||
|
||||
type MessageActionTargetAliasSpec = {
|
||||
aliases: string[];
|
||||
};
|
||||
|
||||
export const messageActionTargetAliases = {
|
||||
read: { aliases: ["messageId"] },
|
||||
pin: { aliases: ["messageId"] },
|
||||
unpin: { aliases: ["messageId"] },
|
||||
"list-pins": { aliases: ["chatId"] },
|
||||
"channel-info": { aliases: ["chatId"] },
|
||||
} satisfies Partial<Record<ChannelMessageActionName, MessageActionTargetAliasSpec>>;
|
||||
@@ -1,140 +0,0 @@
|
||||
import {
|
||||
collectConditionalChannelFieldAssignments,
|
||||
collectSimpleChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
hasOwnProperty,
|
||||
normalizeSecretStringValue,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.feishu.accounts.*.appSecret",
|
||||
targetType: "channels.feishu.accounts.*.appSecret",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.accounts.*.appSecret",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.accounts.*.encryptKey",
|
||||
targetType: "channels.feishu.accounts.*.encryptKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.accounts.*.encryptKey",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.accounts.*.verificationToken",
|
||||
targetType: "channels.feishu.accounts.*.verificationToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.accounts.*.verificationToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.appSecret",
|
||||
targetType: "channels.feishu.appSecret",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.appSecret",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.encryptKey",
|
||||
targetType: "channels.feishu.encryptKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.encryptKey",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.feishu.verificationToken",
|
||||
targetType: "channels.feishu.verificationToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.feishu.verificationToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "feishu");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { channel: feishu, surface } = resolved;
|
||||
collectSimpleChannelFieldAssignments({
|
||||
channelKey: "feishu",
|
||||
field: "appSecret",
|
||||
channel: feishu,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topInactiveReason: "no enabled account inherits this top-level Feishu appSecret.",
|
||||
accountInactiveReason: "Feishu account is disabled.",
|
||||
});
|
||||
const baseConnectionMode =
|
||||
normalizeSecretStringValue(feishu.connectionMode) === "webhook" ? "webhook" : "websocket";
|
||||
const resolveAccountMode = (account: Record<string, unknown>) =>
|
||||
hasOwnProperty(account, "connectionMode")
|
||||
? normalizeSecretStringValue(account.connectionMode)
|
||||
: baseConnectionMode;
|
||||
collectConditionalChannelFieldAssignments({
|
||||
channelKey: "feishu",
|
||||
field: "encryptKey",
|
||||
channel: feishu,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
|
||||
topLevelInheritedAccountActive: ({ account, enabled }) =>
|
||||
enabled &&
|
||||
!hasOwnProperty(account, "encryptKey") &&
|
||||
resolveAccountMode(account) === "webhook",
|
||||
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
|
||||
topInactiveReason: "no enabled Feishu webhook-mode surface inherits this top-level encryptKey.",
|
||||
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
|
||||
});
|
||||
collectConditionalChannelFieldAssignments({
|
||||
channelKey: "feishu",
|
||||
field: "verificationToken",
|
||||
channel: feishu,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActiveWithoutAccounts: baseConnectionMode === "webhook",
|
||||
topLevelInheritedAccountActive: ({ account, enabled }) =>
|
||||
enabled &&
|
||||
!hasOwnProperty(account, "verificationToken") &&
|
||||
resolveAccountMode(account) === "webhook",
|
||||
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === "webhook",
|
||||
topInactiveReason:
|
||||
"no enabled Feishu webhook-mode surface inherits this top-level verificationToken.",
|
||||
accountInactiveReason: "Feishu account is disabled or not running in webhook mode.",
|
||||
});
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: unknown): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean {
|
||||
const channels = asRecord(cfg.channels);
|
||||
const feishu = asRecord(channels?.feishu);
|
||||
if (!feishu || feishu.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseTools = asRecord(feishu.tools);
|
||||
const baseDocEnabled = baseTools?.doc !== false;
|
||||
const baseAppId = hasNonEmptyString(feishu.appId);
|
||||
const baseAppSecret = hasConfiguredSecretInput(feishu.appSecret, cfg.secrets?.defaults);
|
||||
const baseConfigured = baseAppId && baseAppSecret;
|
||||
|
||||
const accounts = asRecord(feishu.accounts);
|
||||
if (!accounts || Object.keys(accounts).length === 0) {
|
||||
return baseDocEnabled && baseConfigured;
|
||||
}
|
||||
|
||||
for (const accountValue of Object.values(accounts)) {
|
||||
const account = asRecord(accountValue) ?? {};
|
||||
if (account.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
const accountTools = asRecord(account.tools);
|
||||
const effectiveTools = accountTools ?? baseTools;
|
||||
const docEnabled = effectiveTools?.doc !== false;
|
||||
if (!docEnabled) {
|
||||
continue;
|
||||
}
|
||||
const accountConfigured =
|
||||
(hasNonEmptyString(account.appId) || baseAppId) &&
|
||||
(hasConfiguredSecretInput(account.appSecret, cfg.secrets?.defaults) || baseAppSecret);
|
||||
if (accountConfigured) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collectFeishuSecurityAuditFindings(params: { cfg: OpenClawConfig }) {
|
||||
if (!isFeishuDocToolEnabled(params.cfg)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
checkId: "channels.feishu.doc_owner_open_id",
|
||||
severity: "warn" as const,
|
||||
title: "Feishu doc create can grant requester permissions",
|
||||
detail:
|
||||
'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.',
|
||||
remediation:
|
||||
"Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -36,7 +36,10 @@ function resolveImplicitToolAccountId(params: {
|
||||
}
|
||||
|
||||
const contextualAccountId = normalizeOptionalAccountId(params.defaultAccountId);
|
||||
if (contextualAccountId && listFeishuAccountIds(params.api.config).includes(contextualAccountId)) {
|
||||
if (
|
||||
contextualAccountId &&
|
||||
listFeishuAccountIds(params.api.config).includes(contextualAccountId)
|
||||
) {
|
||||
const contextualAccount = resolveFeishuAccount({
|
||||
cfg: params.api.config,
|
||||
accountId: contextualAccountId,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
type OpenClawConfig,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./channel.deps.runtime.js";
|
||||
import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
@@ -219,7 +218,6 @@ export const googlechatPlugin = createChatChannelPlugin({
|
||||
groupModel: "route",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings,
|
||||
},
|
||||
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isGoogleChatMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
|
||||
if (!withoutPrefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const withoutUsers = withoutPrefix.replace(/^users\//i, "");
|
||||
return withoutUsers.includes("@");
|
||||
}
|
||||
|
||||
export const collectGoogleChatMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "googlechat",
|
||||
detector: isGoogleChatMutableAllowEntry,
|
||||
collectLists: (scope) => {
|
||||
const lists = [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
];
|
||||
const dm = asObjectRecord(scope.account.dm);
|
||||
if (dm) {
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.dm.allowFrom`,
|
||||
list: dm.allowFrom,
|
||||
});
|
||||
}
|
||||
const groups = asObjectRecord(scope.account.groups);
|
||||
if (groups) {
|
||||
for (const [groupKey, groupRaw] of Object.entries(groups)) {
|
||||
const group = asObjectRecord(groupRaw);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
|
||||
list: group.users,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lists;
|
||||
},
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
getChannelSurface,
|
||||
hasOwnProperty,
|
||||
pushAssignment,
|
||||
pushInactiveSurfaceWarning,
|
||||
pushWarning,
|
||||
resolveChannelAccountSurface,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
type GoogleChatAccountLike = {
|
||||
serviceAccount?: unknown;
|
||||
serviceAccountRef?: unknown;
|
||||
accounts?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.googlechat.accounts.*.serviceAccount",
|
||||
targetType: "channels.googlechat.serviceAccount",
|
||||
targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"],
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.googlechat.accounts.*.serviceAccount",
|
||||
refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef",
|
||||
secretShape: "sibling_ref",
|
||||
expectedResolvedValue: "string-or-object",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
accountIdPathSegmentIndex: 3,
|
||||
},
|
||||
{
|
||||
id: "channels.googlechat.serviceAccount",
|
||||
targetType: "channels.googlechat.serviceAccount",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.googlechat.serviceAccount",
|
||||
refPathPattern: "channels.googlechat.serviceAccountRef",
|
||||
secretShape: "sibling_ref",
|
||||
expectedResolvedValue: "string-or-object",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
function resolveSecretInputRef(params: {
|
||||
value: unknown;
|
||||
refValue?: unknown;
|
||||
defaults?: SecretDefaults;
|
||||
}) {
|
||||
const explicitRef = coerceSecretRef(params.refValue, params.defaults);
|
||||
const inlineRef = explicitRef ? null : coerceSecretRef(params.value, params.defaults);
|
||||
return {
|
||||
explicitRef,
|
||||
inlineRef,
|
||||
ref: explicitRef ?? inlineRef,
|
||||
};
|
||||
}
|
||||
|
||||
function collectGoogleChatAccountAssignment(params: {
|
||||
target: GoogleChatAccountLike;
|
||||
path: string;
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
active?: boolean;
|
||||
inactiveReason?: string;
|
||||
}): void {
|
||||
const { explicitRef, ref } = resolveSecretInputRef({
|
||||
value: params.target.serviceAccount,
|
||||
refValue: params.target.serviceAccountRef,
|
||||
defaults: params.defaults,
|
||||
});
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
if (params.active === false) {
|
||||
pushInactiveSurfaceWarning({
|
||||
context: params.context,
|
||||
path: `${params.path}.serviceAccount`,
|
||||
details: params.inactiveReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
explicitRef &&
|
||||
params.target.serviceAccount !== undefined &&
|
||||
!coerceSecretRef(params.target.serviceAccount, params.defaults)
|
||||
) {
|
||||
pushWarning(params.context, {
|
||||
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
|
||||
path: params.path,
|
||||
message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`,
|
||||
});
|
||||
}
|
||||
pushAssignment(params.context, {
|
||||
ref,
|
||||
path: `${params.path}.serviceAccount`,
|
||||
expected: "string-or-object",
|
||||
apply: (value) => {
|
||||
params.target.serviceAccount = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "googlechat");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const googleChat = resolved.channel as GoogleChatAccountLike;
|
||||
const surface = resolveChannelAccountSurface(googleChat as Record<string, unknown>);
|
||||
const topLevelServiceAccountActive = !surface.channelEnabled
|
||||
? false
|
||||
: !surface.hasExplicitAccounts
|
||||
? true
|
||||
: surface.accounts.some(
|
||||
({ account, enabled }) =>
|
||||
enabled &&
|
||||
!hasOwnProperty(account, "serviceAccount") &&
|
||||
!hasOwnProperty(account, "serviceAccountRef"),
|
||||
);
|
||||
collectGoogleChatAccountAssignment({
|
||||
target: googleChat,
|
||||
path: "channels.googlechat",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: topLevelServiceAccountActive,
|
||||
inactiveReason: "no enabled account inherits this top-level Google Chat serviceAccount.",
|
||||
});
|
||||
if (!surface.hasExplicitAccounts) {
|
||||
return;
|
||||
}
|
||||
for (const { accountId, account, enabled } of surface.accounts) {
|
||||
if (
|
||||
!hasOwnProperty(account, "serviceAccount") &&
|
||||
!hasOwnProperty(account, "serviceAccountRef")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
collectGoogleChatAccountAssignment({
|
||||
target: account as GoogleChatAccountLike,
|
||||
path: `channels.googlechat.accounts.${accountId}`,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: enabled,
|
||||
inactiveReason: "Google Chat account is disabled.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,7 @@ const googlechatDmPolicy: ChannelSetupDmPolicy = {
|
||||
dm: {
|
||||
...currentDm,
|
||||
policy,
|
||||
...(policy === "open"
|
||||
? { allowFrom: addWildcardAllowFrom(currentDm?.allowFrom) }
|
||||
: {}),
|
||||
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(currentDm?.allowFrom) } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -328,10 +328,7 @@ describe("googlechat setup", () => {
|
||||
|
||||
expect(next?.channels?.googlechat?.dm?.policy).toBeUndefined();
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open");
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual([
|
||||
"users/123",
|
||||
"*",
|
||||
]);
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual(["users/123", "*"]);
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then unregisters", async () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ export * from "./src/accounts.js";
|
||||
export * from "./src/conversation-bindings.js";
|
||||
export * from "./src/conversation-id.js";
|
||||
export * from "./src/group-policy.js";
|
||||
export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./src/normalize.js";
|
||||
export { IMESSAGE_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./src/outbound-send-deps.js";
|
||||
export * from "./src/probe.js";
|
||||
export * from "./src/target-parsing-helpers.js";
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export { createIMessageTestPlugin } from "./src/test-plugin.js";
|
||||
export {
|
||||
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
|
||||
} from "./src/media-contract.js";
|
||||
export {
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "./src/media-contract.js";
|
||||
@@ -135,9 +135,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
||||
},
|
||||
doctor: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
},
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
createManager: ({ cfg, accountId }) =>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { mergeInboundPathRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveIMessageAccount } from "./accounts.js";
|
||||
|
||||
export const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const;
|
||||
|
||||
export function resolveIMessageAttachmentRoots(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
const account = resolveIMessageAccount(params);
|
||||
return mergeInboundPathRoots(
|
||||
account.config.attachmentRoots,
|
||||
params.cfg.channels?.imessage?.attachmentRoots,
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveIMessageRemoteAttachmentRoots(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
const account = resolveIMessageAccount(params);
|
||||
return mergeInboundPathRoots(
|
||||
account.config.remoteAttachmentRoots,
|
||||
params.cfg.channels?.imessage?.remoteAttachmentRoots,
|
||||
account.config.attachmentRoots,
|
||||
params.cfg.channels?.imessage?.attachmentRoots,
|
||||
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,12 @@ import {
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/host-runtime";
|
||||
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
isInboundPathAllowed,
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
@@ -35,10 +40,6 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveIMessageAccount } from "../accounts.js";
|
||||
import { createIMessageRpcClient } from "../client.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
|
||||
import {
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "../media-contract.js";
|
||||
import { probeIMessage } from "../probe.js";
|
||||
import { sendMessageIMessage } from "../send.js";
|
||||
import { normalizeIMessageHandle } from "../targets.js";
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget } from "./normalize.js";
|
||||
|
||||
describe("normalizeIMessageMessagingTarget", () => {
|
||||
it("normalizes blank inputs to undefined", () => {
|
||||
expect(normalizeIMessageMessagingTarget(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves service prefixes for handles", () => {
|
||||
expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333");
|
||||
});
|
||||
|
||||
it("drops service prefixes for chat targets", () => {
|
||||
expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123");
|
||||
expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc");
|
||||
expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chatidentifier:foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeIMessageTargetId", () => {
|
||||
it("detects common iMessage target forms", () => {
|
||||
expect(looksLikeIMessageTargetId("sms:+15555550123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("chat_id:123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("user@example.com")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("+15555550123")).toBe(true);
|
||||
expect(looksLikeIMessageTargetId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type ResolvedIrcAccount,
|
||||
} from "./accounts.js";
|
||||
import { IrcChannelConfigSchema } from "./config-schema.js";
|
||||
import { collectIrcMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { monitorIrcProvider } from "./monitor.js";
|
||||
import {
|
||||
normalizeIrcMessagingTarget,
|
||||
@@ -188,10 +187,6 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
|
||||
},
|
||||
}),
|
||||
},
|
||||
doctor: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
collectMutableAllowlistWarnings: collectIrcMutableAllowlistWarnings,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isIrcMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim().toLowerCase();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = text
|
||||
.replace(/^irc:/, "")
|
||||
.replace(/^user:/, "")
|
||||
.trim();
|
||||
|
||||
return !normalized.includes("!") && !normalized.includes("@");
|
||||
}
|
||||
|
||||
export const collectIrcMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "irc",
|
||||
detector: isIrcMutableAllowEntry,
|
||||
collectLists: (scope) => {
|
||||
const lists = [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.allowFrom`,
|
||||
list: scope.account.allowFrom,
|
||||
},
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
];
|
||||
const groups = asObjectRecord(scope.account.groups);
|
||||
if (groups) {
|
||||
for (const [groupKey, groupRaw] of Object.entries(groups)) {
|
||||
const group = asObjectRecord(groupRaw);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`,
|
||||
list: group.allowFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lists;
|
||||
},
|
||||
});
|
||||
@@ -1,51 +1,38 @@
|
||||
// Private runtime barrel for the bundled IRC extension.
|
||||
// Keep this barrel thin and generic-only.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export {
|
||||
buildBaseChannelStatusSummary,
|
||||
createAccountStatusSink,
|
||||
chunkTextForOutbound,
|
||||
createChannelPairingController,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deliverFormattedTextWithAttachments,
|
||||
dispatchInboundReplyWithBase,
|
||||
getChatChannelMeta,
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
isDangerousNameMatchingEnabled,
|
||||
logInboundDrop,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveControlCommandGate,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/irc";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
ChannelPlugin,
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyBySenderConfig,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
getChatChannelMeta,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
buildBaseChannelStatusSummary,
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
export {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
deliverFormattedTextWithAttachments,
|
||||
formatTextWithAttachmentLinks,
|
||||
resolveOutboundMediaUrls,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
isDangerousNameMatchingEnabled,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
OpenClawConfig,
|
||||
OutboundReplyPayload,
|
||||
PluginRuntime,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/irc";
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
|
||||
export {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
listLineAccountIds,
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
} from "./src/accounts.js";
|
||||
@@ -2,19 +2,16 @@
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
ChannelGatewayContext,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export { clearAccountEntryFields } from "openclaw/plugin-sdk/core";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/line";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
|
||||
export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
export {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
@@ -49,7 +46,6 @@ export {
|
||||
sendMessageLine,
|
||||
} from "./src/send.js";
|
||||
export { monitorLineProvider } from "./src/monitor.js";
|
||||
export { hasLineDirectives, parseLineDirectives } from "./src/reply-payload-transform.js";
|
||||
|
||||
export * from "./src/accounts.js";
|
||||
export * from "./src/bot-access.js";
|
||||
@@ -59,7 +55,6 @@ export * from "./src/download.js";
|
||||
export * from "./src/group-keys.js";
|
||||
export * from "./src/markdown-to-line.js";
|
||||
export * from "./src/probe.js";
|
||||
export * from "./src/reply-payload-transform.js";
|
||||
export * from "./src/send.js";
|
||||
export * from "./src/signature.js";
|
||||
export * from "./src/template-messages.js";
|
||||
|
||||
@@ -94,9 +94,7 @@ export function resolveLineAccount(params: {
|
||||
accountId?: string;
|
||||
}): ResolvedLineAccount {
|
||||
const cfg = params.cfg;
|
||||
const accountId = normalizeSharedAccountId(
|
||||
params.accountId ?? resolveDefaultLineAccountId(cfg),
|
||||
);
|
||||
const accountId = normalizeSharedAccountId(params.accountId ?? resolveDefaultLineAccountId(cfg));
|
||||
const lineConfig = cfg.channels?.line as LineConfig | undefined;
|
||||
const accounts = lineConfig?.accounts;
|
||||
const accountConfig =
|
||||
|
||||
@@ -8,7 +8,6 @@ import { lineChannelPluginCommon } from "./channel-shared.js";
|
||||
import { lineGatewayAdapter } from "./gateway.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
import { lineOutboundAdapter } from "./outbound.js";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import { pushMessageLine } from "./send.js";
|
||||
import { lineSetupAdapter } from "./setup-core.js";
|
||||
@@ -75,12 +74,6 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
||||
},
|
||||
resolveInboundConversation: ({ to, conversationId }) =>
|
||||
resolveLineInboundConversation({ to, conversationId }),
|
||||
transformReplyPayload: ({ payload }) => {
|
||||
if (!payload.text || !hasLineDirectives(payload.text)) {
|
||||
return payload;
|
||||
}
|
||||
return parseLineDirectives(payload);
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (id) => {
|
||||
const trimmed = id?.trim();
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
} from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { resolveDefaultLineAccountId } from "./accounts.js";
|
||||
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
|
||||
import { createLineBot } from "./bot.js";
|
||||
import { processLineMessage } from "./markdown-to-line.js";
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
showLoadingAnimation,
|
||||
} from "./send.js";
|
||||
import { buildTemplateMessageFromPayload } from "./template-messages.js";
|
||||
import { resolveDefaultLineAccountId } from "./accounts.js";
|
||||
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
|
||||
import { createLineNodeWebhookHandler } from "./webhook-node.js";
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export {
|
||||
createMatrixThreadBindingManager,
|
||||
resetMatrixThreadBindingsForTests,
|
||||
} from "./src/matrix/thread-bindings.js";
|
||||
export { setMatrixRuntime } from "./src/runtime.js";
|
||||
export {
|
||||
namedAccountPromotionKeys,
|
||||
resolveSingleAccountPromotionTarget,
|
||||
singleAccountKeysToMove,
|
||||
} from "./src/setup-contract.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
export { matrixSetupAdapter } from "./src/setup-core.js";
|
||||
export { matrixSetupWizard } from "./src/setup-surface.js";
|
||||
@@ -30,10 +30,10 @@ export type {
|
||||
OpenClawConfig,
|
||||
PluginRuntime,
|
||||
RuntimeLogger,
|
||||
RuntimeEnv,
|
||||
WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export { formatZonedTimestamp } from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/matrix-runtime-shared";
|
||||
export { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";
|
||||
|
||||
export function chunkTextForOutbound(text: string, limit: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./src/runtime-heavy-api.js";
|
||||
@@ -1,124 +1,142 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./account-selection.js";
|
||||
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
describe("matrix account selection", () => {
|
||||
it("resolves configured account ids from non-canonical account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
describe("Matrix account selection topology", () => {
|
||||
it("includes a top-level default account when its auth is actually complete", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "default-token",
|
||||
accounts: {
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
});
|
||||
|
||||
it("matches the default account against normalized Matrix account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "Team Ops",
|
||||
accounts: {
|
||||
"Ops Bot": { homeserver: "https://matrix.example.org" },
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: { homeserver: "https://matrix.example.org" },
|
||||
alerts: { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it("finds the raw Matrix account entry by normalized account id", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
"Team Ops": {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers env-backed named Matrix accounts during enumeration", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats mixed default and named env-backed Matrix accounts as multi-account", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "team-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]);
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true);
|
||||
});
|
||||
|
||||
it("discovers default Matrix accounts backed only by global env vars", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
it("does not materialize a top-level default account from partial shared auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "shared-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from partial global auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "shared-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not materialize a top-level default account from homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@default:example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from global homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
MATRIX_USER_ID: "@default:example.org",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("counts env-backed named accounts when shared homeserver comes from channel config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps env-backed named accounts that rely on cached credentials", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_USER_ID: "@ops:example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
|
||||
import {
|
||||
MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
MATRIX_DEFAULT_DEVICE_ID,
|
||||
MATRIX_DEFAULT_USER_ID,
|
||||
MATRIX_OPS_ACCESS_TOKEN,
|
||||
MATRIX_OPS_ACCOUNT_ID,
|
||||
MATRIX_OPS_DEVICE_ID,
|
||||
MATRIX_OPS_USER_ID,
|
||||
MATRIX_TEST_HOMESERVER,
|
||||
writeFile,
|
||||
writeMatrixCredentials,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
function createDefaultMatrixConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeDefaultLegacyCryptoFixture(home: string) {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const cfg = createDefaultMatrixConfig();
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
});
|
||||
writeFile(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
|
||||
);
|
||||
return { cfg, rootDir };
|
||||
}
|
||||
|
||||
function createOpsLegacyCryptoFixture(params: {
|
||||
home: string;
|
||||
accessToken?: string;
|
||||
includeStoredCredentials?: boolean;
|
||||
}) {
|
||||
const stateDir = path.join(params.home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
|
||||
);
|
||||
if (params.includeStoredCredentials) {
|
||||
writeMatrixCredentials(stateDir, {
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
});
|
||||
}
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_OPS_USER_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
});
|
||||
return { rootDir };
|
||||
}
|
||||
|
||||
describe("matrix legacy encrypted-state migration", () => {
|
||||
afterEach(() => {});
|
||||
|
||||
it("extracts a saved backup key into the new recovery-key path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 12, backedUp: 12 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
|
||||
});
|
||||
});
|
||||
|
||||
it("skips migration when no legacy Matrix plans exist", async () => {
|
||||
await withTempHome(async () => {
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: createDefaultMatrixConfig(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
migrated: false,
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 15, backedUp: 10 },
|
||||
backupVersion: null,
|
||||
decryptionKeyBase64: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
|
||||
);
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as { restoreStatus: string };
|
||||
expect(state.restoreStatus).toBe("manual-action-required");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers stored credentials for named accounts when config is token-only", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { rootDir } = createOpsLegacyCryptoFixture({
|
||||
home,
|
||||
includeStoredCredentials: true,
|
||||
});
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
accessToken: MATRIX_OPS_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
roomKeyCounts: { total: 1, backedUp: 1 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "b3Bz",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
|
||||
|
||||
function writeFile(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value, "utf-8");
|
||||
}
|
||||
|
||||
describe("matrix legacy state migration", () => {
|
||||
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected a migratable Matrix legacy state plan");
|
||||
}
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", "credentials.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-from-cache",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected cached credentials to make Matrix migration resolvable");
|
||||
}
|
||||
|
||||
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,4 +6,4 @@ export {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
} from "./runtime-heavy-api.js";
|
||||
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
|
||||
|
||||
@@ -1124,10 +1124,7 @@ describe("resolveMatrixAuth", () => {
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/_matrix/client/v3/account/whoami",
|
||||
);
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expect(auth).toMatchObject({
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export const MATRIX_MEDIA_SIZE_LIMIT_ERROR_MESSAGE = "Matrix media exceeds configured size limit";
|
||||
|
||||
export class MatrixMediaSizeLimitError extends Error {
|
||||
readonly code = "MATRIX_MEDIA_SIZE_LIMIT" as const;
|
||||
|
||||
constructor(message = MATRIX_MEDIA_SIZE_LIMIT_ERROR_MESSAGE, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "MatrixMediaSizeLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isMatrixMediaSizeLimitError(err: unknown): err is MatrixMediaSizeLimitError {
|
||||
if (err instanceof MatrixMediaSizeLimitError) {
|
||||
return true;
|
||||
}
|
||||
if (!(err instanceof Error) || err.cause === undefined) {
|
||||
return false;
|
||||
}
|
||||
return isMatrixMediaSizeLimitError(err.cause);
|
||||
}
|
||||
@@ -26,13 +26,9 @@ function resolveMatrixMediaLabel(
|
||||
|
||||
function formatMatrixAttachmentMarker(params: {
|
||||
kind?: MatrixMessageAttachmentKind;
|
||||
tooLarge?: boolean;
|
||||
unavailable?: boolean;
|
||||
}): string {
|
||||
const label = resolveMatrixMediaLabel(params.kind);
|
||||
if (params.tooLarge) {
|
||||
return `[matrix ${label} too large]`;
|
||||
}
|
||||
return params.unavailable ? `[matrix ${label} unavailable]` : `[matrix ${label}]`;
|
||||
}
|
||||
|
||||
@@ -100,7 +96,6 @@ export function resolveMatrixMessageBody(params: {
|
||||
|
||||
export function formatMatrixAttachmentText(params: {
|
||||
attachment?: MatrixMessageAttachmentSummary;
|
||||
tooLarge?: boolean;
|
||||
unavailable?: boolean;
|
||||
}): string | undefined {
|
||||
if (!params.attachment) {
|
||||
@@ -108,7 +103,6 @@ export function formatMatrixAttachmentText(params: {
|
||||
}
|
||||
return formatMatrixAttachmentMarker({
|
||||
kind: params.attachment.kind,
|
||||
tooLarge: params.tooLarge,
|
||||
unavailable: params.unavailable,
|
||||
});
|
||||
}
|
||||
@@ -116,13 +110,11 @@ export function formatMatrixAttachmentText(params: {
|
||||
export function formatMatrixMessageText(params: {
|
||||
body?: string;
|
||||
attachment?: MatrixMessageAttachmentSummary;
|
||||
tooLarge?: boolean;
|
||||
unavailable?: boolean;
|
||||
}): string | undefined {
|
||||
const body = params.body?.trim() ?? "";
|
||||
const marker = formatMatrixAttachmentText({
|
||||
attachment: params.attachment,
|
||||
tooLarge: params.tooLarge,
|
||||
unavailable: params.unavailable,
|
||||
});
|
||||
if (!marker) {
|
||||
@@ -153,17 +145,3 @@ export function formatMatrixMediaUnavailableText(params: {
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMatrixMediaTooLargeText(params: {
|
||||
body?: string;
|
||||
filename?: string;
|
||||
msgtype?: string;
|
||||
}): string {
|
||||
return (
|
||||
formatMatrixMessageText({
|
||||
body: resolveMatrixMessageBody(params),
|
||||
attachment: resolveMatrixMessageAttachment(params),
|
||||
tooLarge: true,
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installMatrixMonitorTestRuntime } from "../../test-runtime.js";
|
||||
import { MatrixMediaSizeLimitError } from "../media-errors.js";
|
||||
import {
|
||||
createMatrixHandlerTestHarness,
|
||||
createMatrixRoomMessageEvent,
|
||||
@@ -10,13 +9,9 @@ const { downloadMatrixMediaMock } = vi.hoisted(() => ({
|
||||
downloadMatrixMediaMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./media.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./media.js")>();
|
||||
return {
|
||||
...actual,
|
||||
downloadMatrixMedia: (...args: unknown[]) => downloadMatrixMediaMock(...args),
|
||||
};
|
||||
});
|
||||
vi.mock("./media.js", () => ({
|
||||
downloadMatrixMedia: (...args: unknown[]) => downloadMatrixMediaMock(...args),
|
||||
}));
|
||||
|
||||
function createMediaFailureHarness() {
|
||||
const logger = {
|
||||
@@ -216,52 +211,4 @@ describe("createMatrixRoomMessageHandler media failures", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a too-large marker when the download is rejected due to size limit", async () => {
|
||||
downloadMatrixMediaMock.mockRejectedValue(new MatrixMediaSizeLimitError());
|
||||
const { handler, recordInboundSession } = createMediaFailureHarness();
|
||||
|
||||
await handler(
|
||||
"!room:example.org",
|
||||
createImageEvent({
|
||||
msgtype: "m.image",
|
||||
body: "big-photo.jpg",
|
||||
url: "mxc://example/big-image",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordInboundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
RawBody: "[matrix image attachment too large]",
|
||||
CommandBody: "[matrix image attachment too large]",
|
||||
MediaPath: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a real caption while marking the attachment too large on size limit error", async () => {
|
||||
downloadMatrixMediaMock.mockRejectedValue(new MatrixMediaSizeLimitError());
|
||||
const { handler, recordInboundSession } = createMediaFailureHarness();
|
||||
|
||||
await handler(
|
||||
"!room:example.org",
|
||||
createImageEvent({
|
||||
msgtype: "m.image",
|
||||
body: "check this out",
|
||||
filename: "large-photo.jpg",
|
||||
url: "mxc://example/big-image",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordInboundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
RawBody: "check this out\n\n[matrix image attachment too large]",
|
||||
CommandBody: "check this out\n\n[matrix image attachment too large]",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,7 @@ import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runti
|
||||
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
|
||||
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
|
||||
import { createMatrixDraftStream } from "../draft-stream.js";
|
||||
import { isMatrixMediaSizeLimitError } from "../media-errors.js";
|
||||
import {
|
||||
formatMatrixMediaTooLargeText,
|
||||
formatMatrixMediaUnavailableText,
|
||||
formatMatrixMessageText,
|
||||
resolveMatrixMessageAttachment,
|
||||
@@ -141,7 +139,6 @@ function resolveMatrixInboundBodyText(params: {
|
||||
msgtype?: string;
|
||||
hadMediaUrl: boolean;
|
||||
mediaDownloadFailed: boolean;
|
||||
mediaSizeLimitExceeded?: boolean;
|
||||
}): string {
|
||||
if (params.mediaPlaceholder) {
|
||||
return params.rawBody || params.mediaPlaceholder;
|
||||
@@ -149,13 +146,6 @@ function resolveMatrixInboundBodyText(params: {
|
||||
if (!params.mediaDownloadFailed || !params.hadMediaUrl) {
|
||||
return params.rawBody;
|
||||
}
|
||||
if (params.mediaSizeLimitExceeded) {
|
||||
return formatMatrixMediaTooLargeText({
|
||||
body: params.rawBody,
|
||||
filename: params.filename,
|
||||
msgtype: params.msgtype,
|
||||
});
|
||||
}
|
||||
return formatMatrixMediaUnavailableText({
|
||||
body: params.rawBody,
|
||||
filename: params.filename,
|
||||
@@ -776,7 +766,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
placeholder: string;
|
||||
} | null = null;
|
||||
let mediaDownloadFailed = false;
|
||||
let mediaSizeLimitExceeded = false;
|
||||
const finalContentUrl =
|
||||
"url" in content && typeof content.url === "string" ? content.url : undefined;
|
||||
const finalContentFile =
|
||||
@@ -806,9 +795,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
});
|
||||
} catch (err) {
|
||||
mediaDownloadFailed = true;
|
||||
if (isMatrixMediaSizeLimitError(err)) {
|
||||
mediaSizeLimitExceeded = true;
|
||||
}
|
||||
const errorText = err instanceof Error ? err.message : String(err);
|
||||
logVerboseMessage(
|
||||
`matrix: media download failed room=${roomId} id=${event.event_id ?? "unknown"} type=${content.msgtype} error=${errorText}`,
|
||||
@@ -831,7 +817,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
msgtype: content.msgtype,
|
||||
hadMediaUrl: Boolean(finalMediaUrl),
|
||||
mediaDownloadFailed,
|
||||
mediaSizeLimitExceeded,
|
||||
});
|
||||
if (!bodyText) {
|
||||
await commitInboundEventIfClaimed();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../../../runtime-api.js";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import { MatrixMediaSizeLimitError } from "../media-errors.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
|
||||
function createEncryptedClient() {
|
||||
@@ -112,31 +111,12 @@ describe("downloadMatrixMedia", () => {
|
||||
maxBytes: 1024,
|
||||
file,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MatrixMediaSizeLimitError);
|
||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
||||
|
||||
expect(decryptMedia).not.toHaveBeenCalled();
|
||||
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves typed size-limit errors from plain media downloads", async () => {
|
||||
const tooLargeError = new MatrixMediaSizeLimitError(
|
||||
"Matrix media exceeds configured size limit (8192 bytes > 4096 bytes)",
|
||||
);
|
||||
const downloadContent = vi.fn().mockRejectedValue(tooLargeError);
|
||||
const client = {
|
||||
downloadContent,
|
||||
} as unknown as import("../sdk.js").MatrixClient;
|
||||
|
||||
await expect(
|
||||
downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: "mxc://example/file",
|
||||
contentType: "image/png",
|
||||
maxBytes: 4096,
|
||||
}),
|
||||
).rejects.toBe(tooLargeError);
|
||||
});
|
||||
|
||||
it("passes byte limits through plain media downloads", async () => {
|
||||
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("plain"));
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { MatrixMediaSizeLimitError, isMatrixMediaSizeLimitError } from "../media-errors.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
// Type for encrypted file info
|
||||
@@ -31,9 +30,6 @@ async function fetchMatrixMediaBuffer(params: {
|
||||
});
|
||||
return { buffer };
|
||||
} catch (err) {
|
||||
if (isMatrixMediaSizeLimitError(err)) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
|
||||
}
|
||||
}
|
||||
@@ -60,7 +56,7 @@ async function fetchEncryptedMediaBuffer(params: {
|
||||
);
|
||||
|
||||
if (decrypted.byteLength > params.maxBytes) {
|
||||
throw new MatrixMediaSizeLimitError();
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
return { buffer: decrypted };
|
||||
@@ -81,7 +77,7 @@ export async function downloadMatrixMedia(params: {
|
||||
} | null> {
|
||||
let fetched: { buffer: Buffer; headerType?: string } | null;
|
||||
if (typeof params.sizeBytes === "number" && params.sizeBytes > params.maxBytes) {
|
||||
throw new MatrixMediaSizeLimitError();
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
if (params.file) {
|
||||
|
||||
@@ -2,29 +2,30 @@
|
||||
// Keep monitor internals off the broad package runtime-api barrel so monitor
|
||||
// tests and shared workers do not pull unrelated Matrix helper surfaces.
|
||||
|
||||
export type { NormalizedLocation, PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/core";
|
||||
export type { BlockReplyContext, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export { ensureConfiguredAcpBindingReady } from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
canonicalizeAllowlistWithResolvedIds,
|
||||
formatAllowlistMatchMeta,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "openclaw/plugin-sdk/allow-from";
|
||||
export { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export { createTypingCallbacks } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export {
|
||||
formatLocationText,
|
||||
logInboundDrop,
|
||||
toLocationContext,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export { logTypingFailure, resolveAckReaction } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export {
|
||||
buildChannelKeyCandidates,
|
||||
canonicalizeAllowlistWithResolvedIds,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
formatAllowlistMatchMeta,
|
||||
formatLocationText,
|
||||
getAgentScopedMediaLocalRoots,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
resolveAckReaction,
|
||||
resolveChannelEntryMatch,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
summarizeMapping,
|
||||
toLocationContext,
|
||||
type BlockReplyContext,
|
||||
type MarkdownTableMode,
|
||||
type NormalizedLocation,
|
||||
type OpenClawConfig,
|
||||
type PluginRuntime,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
type RuntimeLogger,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
export { ensureConfiguredAcpBindingReady } from "openclaw/plugin-sdk/matrix-runtime-heavy";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MatrixMediaSizeLimitError } from "../media-errors.js";
|
||||
import { performMatrixRequest } from "./transport.js";
|
||||
|
||||
describe("performMatrixRequest", () => {
|
||||
@@ -32,7 +31,7 @@ describe("performMatrixRequest", () => {
|
||||
maxBytes: 1024,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MatrixMediaSizeLimitError);
|
||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
||||
});
|
||||
|
||||
it("applies streaming byte limits when raw responses omit content-length", async () => {
|
||||
@@ -65,7 +64,7 @@ describe("performMatrixRequest", () => {
|
||||
maxBytes: 1024,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MatrixMediaSizeLimitError);
|
||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
||||
});
|
||||
|
||||
it("uses the matrix-specific idle-timeout error for stalled raw downloads", async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type SsrFPolicy,
|
||||
} from "../../runtime-api.js";
|
||||
import { MatrixMediaSizeLimitError } from "../media-errors.js";
|
||||
import { readResponseWithLimit } from "./read-response-with-limit.js";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
@@ -281,7 +280,7 @@ export async function performMatrixRequest(params: {
|
||||
if (params.maxBytes && contentLength) {
|
||||
const length = Number(contentLength);
|
||||
if (Number.isFinite(length) && length > params.maxBytes) {
|
||||
throw new MatrixMediaSizeLimitError(
|
||||
throw new Error(
|
||||
`Matrix media exceeds configured size limit (${length} bytes > ${params.maxBytes} bytes)`,
|
||||
);
|
||||
}
|
||||
@@ -289,7 +288,7 @@ export async function performMatrixRequest(params: {
|
||||
const bytes = params.maxBytes
|
||||
? await readResponseWithLimit(response, params.maxBytes, {
|
||||
onOverflow: ({ maxBytes, size }) =>
|
||||
new MatrixMediaSizeLimitError(
|
||||
new Error(
|
||||
`Matrix media exceeds configured size limit (${size} bytes > ${maxBytes} bytes)`,
|
||||
),
|
||||
chunkTimeoutMs: params.readIdleTimeoutMs,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../../test/helpers/temp-home.js";
|
||||
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
import {
|
||||
hasActionableMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
resolveMatrixMigrationSnapshotMarkerPath,
|
||||
resolveMatrixMigrationSnapshotOutputDir,
|
||||
} from "./migration-snapshot.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
|
||||
|
||||
describe("matrix migration snapshots", () => {
|
||||
it("creates a backup marker after writing a pre-migration snapshot", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8");
|
||||
|
||||
const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" });
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.markerPath).toBe(resolveMatrixMigrationSnapshotMarkerPath(process.env));
|
||||
expect(
|
||||
result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(result.archivePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats resolvable Matrix legacy state as actionable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"legacy":true}',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(
|
||||
hasActionableMatrixMigration({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
env: process.env,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats legacy Matrix crypto as actionable when the extension inspector is present", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICE123" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(
|
||||
hasActionableMatrixMigration({
|
||||
cfg,
|
||||
env: process.env,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,96 +1,36 @@
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
buildChannelConfigSchema,
|
||||
buildProbeChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
createActionGate,
|
||||
formatZonedTimestamp,
|
||||
getChatChannelMeta,
|
||||
jsonResult,
|
||||
loadOutboundMediaFromUrl,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
type PollInput,
|
||||
type ReplyPayload,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
ChannelPlugin,
|
||||
NormalizedLocation,
|
||||
PluginRuntime,
|
||||
RuntimeLogger,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
ChannelToolSend,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export { formatZonedTimestamp } from "openclaw/plugin-sdk/core";
|
||||
export { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
export type { ChannelSetupInput } from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
OpenClawConfig,
|
||||
ContextVisibilityMode,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
export type { WizardPrompter } from "openclaw/plugin-sdk/core";
|
||||
export type { SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export {
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
hasConfiguredSecretInput,
|
||||
mergeAllowFromEntries,
|
||||
moveSingleAccountChannelSectionToDefaultAccount,
|
||||
promptAccountId,
|
||||
promptChannelAccessConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
export * from "openclaw/plugin-sdk/matrix";
|
||||
export {
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
isPrivateOrLoopbackHost,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
ssrfPolicyFromAllowPrivateNetwork,
|
||||
type LookupFn,
|
||||
type SsrFPolicy,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
export { dispatchReplyFromConfigWithSettledDispatcher } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||
export {
|
||||
dispatchReplyFromConfigWithSettledDispatcher,
|
||||
ensureConfiguredAcpBindingReady,
|
||||
resolveConfiguredAcpBindingRecord,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
buildProbeChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export {
|
||||
getSessionBindingService,
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
export { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
export { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
export { normalizePollInput } from "openclaw/plugin-sdk/media-runtime";
|
||||
export { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
|
||||
// resolveMatrixAccountStringValues already comes from plugin-sdk/matrix.
|
||||
// Re-exporting auth-precedence here makes Jiti try to define the same export twice.
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
export { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
|
||||
export {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
} from "./migration-snapshot.js";
|
||||
@@ -1,169 +0,0 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
collectSecretInputAssignment,
|
||||
getChannelSurface,
|
||||
hasConfiguredSecretInputValue,
|
||||
hasOwnProperty,
|
||||
normalizeSecretStringValue,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.matrix.accounts.*.accessToken",
|
||||
targetType: "channels.matrix.accounts.*.accessToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.matrix.accounts.*.accessToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.matrix.accounts.*.password",
|
||||
targetType: "channels.matrix.accounts.*.password",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.matrix.accounts.*.password",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.matrix.accessToken",
|
||||
targetType: "channels.matrix.accessToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.matrix.accessToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.matrix.password",
|
||||
targetType: "channels.matrix.password",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.matrix.password",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "matrix");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { channel: matrix, surface } = resolved;
|
||||
const envAccessTokenConfigured =
|
||||
normalizeSecretStringValue(params.context.env.MATRIX_ACCESS_TOKEN).length > 0;
|
||||
const defaultScopedAccessTokenConfigured =
|
||||
normalizeSecretStringValue(
|
||||
params.context.env[getMatrixScopedEnvVarNames("default").accessToken],
|
||||
).length > 0;
|
||||
const defaultAccountAccessTokenConfigured = surface.accounts.some(
|
||||
({ accountId, account }) =>
|
||||
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID &&
|
||||
hasConfiguredSecretInputValue(account.accessToken, params.defaults),
|
||||
);
|
||||
const baseAccessTokenConfigured = hasConfiguredSecretInputValue(
|
||||
matrix.accessToken,
|
||||
params.defaults,
|
||||
);
|
||||
collectSecretInputAssignment({
|
||||
value: matrix.accessToken,
|
||||
path: "channels.matrix.accessToken",
|
||||
expected: "string",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: surface.channelEnabled,
|
||||
inactiveReason: "Matrix channel is disabled.",
|
||||
apply: (value) => {
|
||||
matrix.accessToken = value;
|
||||
},
|
||||
});
|
||||
collectSecretInputAssignment({
|
||||
value: matrix.password,
|
||||
path: "channels.matrix.password",
|
||||
expected: "string",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active:
|
||||
surface.channelEnabled &&
|
||||
!(
|
||||
baseAccessTokenConfigured ||
|
||||
envAccessTokenConfigured ||
|
||||
defaultScopedAccessTokenConfigured ||
|
||||
defaultAccountAccessTokenConfigured
|
||||
),
|
||||
inactiveReason:
|
||||
"Matrix channel is disabled or access-token auth is configured for the default Matrix account.",
|
||||
apply: (value) => {
|
||||
matrix.password = value;
|
||||
},
|
||||
});
|
||||
if (!surface.hasExplicitAccounts) {
|
||||
return;
|
||||
}
|
||||
for (const { accountId, account, enabled } of surface.accounts) {
|
||||
if (hasOwnProperty(account, "accessToken")) {
|
||||
collectSecretInputAssignment({
|
||||
value: account.accessToken,
|
||||
path: `channels.matrix.accounts.${accountId}.accessToken`,
|
||||
expected: "string",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: enabled,
|
||||
inactiveReason: "Matrix account is disabled.",
|
||||
apply: (value) => {
|
||||
account.accessToken = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!hasOwnProperty(account, "password")) {
|
||||
continue;
|
||||
}
|
||||
const accountAccessTokenConfigured = hasConfiguredSecretInputValue(
|
||||
account.accessToken,
|
||||
params.defaults,
|
||||
);
|
||||
const scopedEnvAccessTokenConfigured =
|
||||
normalizeSecretStringValue(
|
||||
params.context.env[getMatrixScopedEnvVarNames(accountId).accessToken],
|
||||
).length > 0;
|
||||
const inheritedDefaultAccountAccessTokenConfigured =
|
||||
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID &&
|
||||
(baseAccessTokenConfigured || envAccessTokenConfigured);
|
||||
collectSecretInputAssignment({
|
||||
value: account.password,
|
||||
path: `channels.matrix.accounts.${accountId}.password`,
|
||||
expected: "string",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active:
|
||||
enabled &&
|
||||
!(
|
||||
accountAccessTokenConfigured ||
|
||||
scopedEnvAccessTokenConfigured ||
|
||||
inheritedDefaultAccountAccessTokenConfigured
|
||||
),
|
||||
inactiveReason: "Matrix account is disabled or this account has an accessToken configured.",
|
||||
apply: (value) => {
|
||||
account.password = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/setup";
|
||||
|
||||
const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = [
|
||||
"deviceId",
|
||||
"avatarUrl",
|
||||
"initialSyncLimit",
|
||||
"encryption",
|
||||
"allowlistOnly",
|
||||
"allowBots",
|
||||
"blockStreaming",
|
||||
"replyToMode",
|
||||
"threadReplies",
|
||||
"textChunkLimit",
|
||||
"chunkMode",
|
||||
"responsePrefix",
|
||||
"ackReaction",
|
||||
"ackReactionScope",
|
||||
"reactionNotifications",
|
||||
"threadBindings",
|
||||
"startupVerification",
|
||||
"startupVerificationCooldownHours",
|
||||
"mediaMaxMb",
|
||||
"autoJoin",
|
||||
"autoJoinAllowlist",
|
||||
"dm",
|
||||
"groups",
|
||||
"rooms",
|
||||
"actions",
|
||||
] as const;
|
||||
|
||||
const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = [
|
||||
// When named accounts already exist, only move auth/bootstrap fields into the
|
||||
// promoted account. Shared delivery-policy fields stay at the top level.
|
||||
"name",
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
"deviceName",
|
||||
"avatarUrl",
|
||||
"initialSyncLimit",
|
||||
"encryption",
|
||||
] as const;
|
||||
|
||||
export const singleAccountKeysToMove = [...MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE];
|
||||
export const namedAccountPromotionKeys = [...MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS];
|
||||
|
||||
export function resolveSingleAccountPromotionTarget(params: {
|
||||
channel: Record<string, unknown>;
|
||||
}): string {
|
||||
const accounts =
|
||||
typeof params.channel.accounts === "object" && params.channel.accounts
|
||||
? (params.channel.accounts as Record<string, unknown>)
|
||||
: {};
|
||||
const normalizedDefaultAccount =
|
||||
typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim()
|
||||
? normalizeAccountId(params.channel.defaultAccount)
|
||||
: undefined;
|
||||
if (normalizedDefaultAccount) {
|
||||
if (normalizedDefaultAccount !== DEFAULT_ACCOUNT_ID) {
|
||||
const matchedAccountId = Object.entries(accounts).find(
|
||||
([accountId, value]) =>
|
||||
accountId &&
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
normalizeAccountId(accountId) === normalizedDefaultAccount,
|
||||
)?.[0];
|
||||
if (matchedAccountId) {
|
||||
return matchedAccountId;
|
||||
}
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
const namedAccounts = Object.entries(accounts).filter(
|
||||
([accountId, value]) => accountId && typeof value === "object" && value,
|
||||
);
|
||||
if (namedAccounts.length === 1) {
|
||||
return namedAccounts[0][0];
|
||||
}
|
||||
if (
|
||||
namedAccounts.length > 1 &&
|
||||
accounts[DEFAULT_ACCOUNT_ID] &&
|
||||
typeof accounts[DEFAULT_ACCOUNT_ID] === "object"
|
||||
) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
export {
|
||||
registerSessionBindingAdapter,
|
||||
__testing,
|
||||
} from "../../../../src/infra/outbound/session-binding-service.js";
|
||||
export { setActivePluginRegistry } from "../../../../src/plugins/runtime.js";
|
||||
export { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
|
||||
export { createTestRegistry } from "../../../../src/test-utils/channel-plugins.js";
|
||||
export type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
createTestRegistry,
|
||||
registerSessionBindingAdapter,
|
||||
resolveAgentRoute,
|
||||
setActivePluginRegistry,
|
||||
type OpenClawConfig,
|
||||
} from "../../../../test/helpers/plugins/matrix-monitor-route.js";
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
@@ -1,88 +1,4 @@
|
||||
// Private runtime barrel for the bundled Mattermost extension.
|
||||
// Keep this barrel thin and generic-only.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelPlugin,
|
||||
ChatType,
|
||||
HistoryEntry,
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
|
||||
export type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
createDedupeCache,
|
||||
parseStrictPositiveInteger,
|
||||
resolveClientIp,
|
||||
isTrustedProxyAddress,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export { buildComputedAccountStatusSnapshot } from "openclaw/plugin-sdk/channel-status";
|
||||
export { createAccountStatusSink } from "openclaw/plugin-sdk/compat";
|
||||
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export {
|
||||
buildModelsProviderData,
|
||||
listSkillCommandsForAgents,
|
||||
resolveControlCommandGate,
|
||||
resolveStoredModelOverride,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
isDangerousNameMatchingEnabled,
|
||||
loadSessionStore,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveStorePath,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { formatInboundFromLabel } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
export { rawDataToString } from "openclaw/plugin-sdk/browser-support";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
export { normalizeAccountId, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
export { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk/allow-from";
|
||||
export { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-targets";
|
||||
export {
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export {
|
||||
applyAccountNameToChannelSection,
|
||||
applySetupAccountConfigPatch,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
export {
|
||||
getAgentScopedMediaLocalRoots,
|
||||
resolveChannelMediaMaxBytes,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
export { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
export * from "openclaw/plugin-sdk/mattermost";
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { mattermostApprovalAuth } from "./approval-auth.js";
|
||||
import { MattermostChannelConfigSchema } from "./config-surface.js";
|
||||
import { collectMattermostMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
@@ -39,7 +38,6 @@ import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||
import { probeMattermost } from "./mattermost/probe.js";
|
||||
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
||||
import { sendMessageMattermost } from "./mattermost/send.js";
|
||||
import { collectMattermostSlashCallbackPaths } from "./mattermost/slash-commands.js";
|
||||
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
|
||||
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
||||
import {
|
||||
@@ -330,9 +328,6 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
}),
|
||||
},
|
||||
auth: mattermostApprovalAuth,
|
||||
doctor: {
|
||||
collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||
},
|
||||
@@ -406,34 +401,6 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
}),
|
||||
}),
|
||||
gateway: {
|
||||
resolveGatewayAuthBypassPaths: ({ cfg }) => {
|
||||
const base = cfg.channels?.mattermost;
|
||||
const callbackPaths = new Set(
|
||||
collectMattermostSlashCallbackPaths(base?.commands).filter(
|
||||
(path) =>
|
||||
path === "/api/channels/mattermost/command" ||
|
||||
path.startsWith("/api/channels/mattermost/"),
|
||||
),
|
||||
);
|
||||
const accounts = base?.accounts ?? {};
|
||||
for (const account of Object.values(accounts)) {
|
||||
const accountConfig =
|
||||
account && typeof account === "object" && !Array.isArray(account)
|
||||
? (account as {
|
||||
commands?: Parameters<typeof collectMattermostSlashCallbackPaths>[0];
|
||||
})
|
||||
: undefined;
|
||||
for (const path of collectMattermostSlashCallbackPaths(accountConfig?.commands)) {
|
||||
if (
|
||||
path === "/api/channels/mattermost/command" ||
|
||||
path.startsWith("/api/channels/mattermost/")
|
||||
) {
|
||||
callbackPaths.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...callbackPaths];
|
||||
},
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const statusSink = createAccountStatusSink({
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
|
||||
function isMattermostMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = text
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (/^[a-z0-9]{26}$/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const collectMattermostMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "mattermost",
|
||||
detector: isMattermostMutableAllowEntry,
|
||||
collectLists: (scope) => [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.allowFrom`,
|
||||
list: scope.account.allowFrom,
|
||||
},
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,19 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { createMattermostClient, createMattermostDirectChannelWithRetry } from "./client.js";
|
||||
|
||||
describe("createMattermostDirectChannelWithRetry", () => {
|
||||
const mockFetch = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function createMockClient() {
|
||||
@@ -31,16 +23,6 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
return Object.assign(new TypeError("fetch failed"), { cause });
|
||||
}
|
||||
|
||||
async function resolveRetryRun<T>(run: Promise<T>): Promise<T> {
|
||||
await vi.runAllTimersAsync();
|
||||
return await run;
|
||||
}
|
||||
|
||||
function suppressUnhandled<T>(run: Promise<T>): Promise<T> {
|
||||
run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
it("succeeds on first attempt without retries", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -52,11 +34,9 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
const client = createMockClient();
|
||||
const onRetry = vi.fn();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
onRetry,
|
||||
}),
|
||||
);
|
||||
const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
onRetry,
|
||||
});
|
||||
|
||||
expect(result.id).toBe("dm-channel-123");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
@@ -82,13 +62,11 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
const client = createMockClient();
|
||||
const onRetry = vi.fn();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
onRetry,
|
||||
}),
|
||||
);
|
||||
const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
onRetry,
|
||||
});
|
||||
|
||||
expect(result.id).toBe("dm-channel-456");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
@@ -113,12 +91,10 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
});
|
||||
|
||||
// Should retry and succeed on second attempt (port 443 should NOT be treated as 4xx)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
@@ -138,13 +114,12 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
await expect(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow();
|
||||
).rejects.toThrow();
|
||||
|
||||
// Should not retry - only called once (400 is a client error, even though message contains "429")
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
@@ -175,12 +150,10 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
});
|
||||
|
||||
expect(result.id).toBe("dm-channel-789");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
@@ -199,12 +172,10 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
});
|
||||
|
||||
expect(result.id).toBe("dm-channel-abc");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
@@ -227,12 +198,10 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
});
|
||||
|
||||
expect(result.id).toBe("dm-channel-fetch-failed");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
@@ -249,13 +218,12 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
await expect(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("400");
|
||||
).rejects.toThrow("400");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -271,13 +239,12 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
await expect(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("404");
|
||||
).rejects.toThrow("404");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -293,13 +260,12 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
await expect(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 2,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow();
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3); // initial + 2 retries
|
||||
});
|
||||
@@ -332,14 +298,13 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
await expect(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
timeoutMs: 50,
|
||||
maxRetries: 0,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow();
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(abortSignal).toBeDefined();
|
||||
@@ -360,16 +325,14 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
onRetry: (attempt, delayMs) => {
|
||||
delays.push(delayMs);
|
||||
},
|
||||
}),
|
||||
);
|
||||
await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
onRetry: (attempt, delayMs) => {
|
||||
delays.push(delayMs);
|
||||
},
|
||||
});
|
||||
|
||||
expect(delays).toHaveLength(2);
|
||||
// First retry: exponentialDelay = 100ms, jitter = 0-100ms, total = 100-200ms
|
||||
@@ -396,16 +359,14 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 4,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 2500,
|
||||
onRetry: (attempt, delayMs) => {
|
||||
delays.push(delayMs);
|
||||
},
|
||||
}),
|
||||
);
|
||||
await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 4,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 2500,
|
||||
onRetry: (attempt, delayMs) => {
|
||||
delays.push(delayMs);
|
||||
},
|
||||
});
|
||||
|
||||
expect(delays).toHaveLength(4);
|
||||
// All delays should be capped at maxDelayMs
|
||||
@@ -427,13 +388,12 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
await expect(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("400");
|
||||
).rejects.toThrow("400");
|
||||
|
||||
// Should not retry - only called once
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
@@ -450,13 +410,12 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
await expect(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("403");
|
||||
).rejects.toThrow("403");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -474,11 +433,9 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
});
|
||||
|
||||
const client = createMockClient();
|
||||
await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
);
|
||||
await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(capturedSignal).toBeDefined();
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
@@ -497,12 +454,10 @@ describe("createMattermostDirectChannelWithRetry", () => {
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
const result = await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
});
|
||||
|
||||
// Should retry and succeed on second attempt
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -2,21 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runWithReconnect } from "./reconnect.js";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function resolveReconnectRun(promise: Promise<void>): Promise<void> {
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
}
|
||||
|
||||
describe("runWithReconnect", () => {
|
||||
it("retries after connectFn resolves (normal close)", async () => {
|
||||
let callCount = 0;
|
||||
@@ -28,11 +21,10 @@ describe("runWithReconnect", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
@@ -49,12 +41,11 @@ describe("runWithReconnect", () => {
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onError,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||
expect(onError).toHaveBeenCalledTimes(2);
|
||||
@@ -74,15 +65,18 @@ describe("runWithReconnect", () => {
|
||||
throw new Error("connection refused");
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
// Keep this test fast: validate the exponential pattern, not real-time waiting.
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 10,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(6);
|
||||
// 5 errors produce delays: 1, 2, 4, 8, 10(cap)
|
||||
// 6th succeeds -> delay resets to 100
|
||||
// But 6th also aborts → onReconnect NOT called (abort check fires first)
|
||||
expect(delays).toEqual([1, 2, 4, 8, 10]);
|
||||
});
|
||||
|
||||
@@ -96,7 +90,7 @@ describe("runWithReconnect", () => {
|
||||
throw new Error("first failure");
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return;
|
||||
return; // success
|
||||
}
|
||||
if (callCount === 3) {
|
||||
throw new Error("second failure");
|
||||
@@ -104,15 +98,18 @@ describe("runWithReconnect", () => {
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 60_000,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(4);
|
||||
// call 1: fail -> delay 1
|
||||
// call 2: success → delay resets to 1
|
||||
// call 3: fail -> delay 1 (reset held)
|
||||
// call 4: success + abort → no onReconnect
|
||||
expect(delays).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
@@ -143,16 +140,19 @@ describe("runWithReconnect", () => {
|
||||
it("abort signal interrupts backoff sleep immediately", async () => {
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
// Schedule abort to fire 10ms into the 60s sleep
|
||||
setTimeout(() => abort.abort(), 10);
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
const start = Date.now();
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 60_000,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it("applies jitter to reconnect delay when configured", async () => {
|
||||
@@ -167,14 +167,13 @@ describe("runWithReconnect", () => {
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 10,
|
||||
jitterRatio: 0.5,
|
||||
random: () => 1,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(2);
|
||||
expect(delays).toEqual([15]);
|
||||
|
||||
@@ -534,22 +534,6 @@ export function isSlashCommandsEnabled(config: MattermostSlashCommandConfig): bo
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collectMattermostSlashCallbackPaths(raw?: Partial<MattermostSlashCommandConfig>) {
|
||||
const config = resolveSlashCommandConfig(raw);
|
||||
const paths = new Set<string>([config.callbackPath]);
|
||||
if (typeof config.callbackUrl === "string" && config.callbackUrl.trim()) {
|
||||
try {
|
||||
const pathname = new URL(config.callbackUrl).pathname;
|
||||
if (pathname) {
|
||||
paths.add(pathname);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid callback URLs and keep the normalized callback path only.
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the callback URL that Mattermost will POST to when a command is invoked.
|
||||
*/
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
collectSimpleChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.mattermost.accounts.*.botToken",
|
||||
targetType: "channels.mattermost.accounts.*.botToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.mattermost.accounts.*.botToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.mattermost.botToken",
|
||||
targetType: "channels.mattermost.botToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.mattermost.botToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "mattermost");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { channel: mattermost, surface } = resolved;
|
||||
collectSimpleChannelFieldAssignments({
|
||||
channelKey: "mattermost",
|
||||
field: "botToken",
|
||||
channel: mattermost,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topInactiveReason: "no enabled account inherits this top-level Mattermost botToken.",
|
||||
accountInactiveReason: "Mattermost account is disabled.",
|
||||
});
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from "../runtime-api.js";
|
||||
import { msTeamsApprovalAuth } from "./approval-auth.js";
|
||||
import { MSTeamsChannelConfigSchema } from "./config-schema.js";
|
||||
import { collectMSTeamsMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { formatUnknownError } from "./errors.js";
|
||||
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
|
||||
import type { ProbeMSTeamsResult } from "./probe.js";
|
||||
@@ -389,7 +388,6 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
|
||||
groupModel: "hybrid",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: true,
|
||||
collectMutableAllowlistWarnings: collectMSTeamsMutableAllowlistWarnings,
|
||||
},
|
||||
setup: msteamsSetupAdapter,
|
||||
messaging: {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
|
||||
function isMSTeamsMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const withoutPrefix = text.replace(/^(msteams|user):/i, "").trim();
|
||||
return /\s/.test(withoutPrefix) || withoutPrefix.includes("@");
|
||||
}
|
||||
|
||||
export const collectMSTeamsMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "msteams",
|
||||
detector: isMSTeamsMutableAllowEntry,
|
||||
collectLists: (scope) => [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.allowFrom`,
|
||||
list: scope.account.allowFrom,
|
||||
},
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -192,9 +192,7 @@ export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
|
||||
});
|
||||
return setNextcloudTalkAccountConfig(cfg as CoreConfig, resolvedAccountId, {
|
||||
dmPolicy: policy,
|
||||
...(policy === "open"
|
||||
? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) }
|
||||
: {}),
|
||||
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) } : {}),
|
||||
});
|
||||
},
|
||||
promptAllowFrom: promptNextcloudTalkAllowFromForAccount,
|
||||
|
||||
@@ -5,11 +5,7 @@ import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.j
|
||||
import { qqbotPlugin } from "./channel.js";
|
||||
import { qqbotSetupPlugin } from "./channel.setup.js";
|
||||
import { QQBotConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
resolveDefaultQQBotAccountId,
|
||||
resolveQQBotAccount,
|
||||
} from "./config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./config.js";
|
||||
|
||||
describe("qqbot config", () => {
|
||||
it("accepts top-level speech overrides in the manifest schema", () => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
export * from "./src/accounts.js";
|
||||
export * from "./src/format.js";
|
||||
export * from "./src/identity.js";
|
||||
export * from "./src/install-signal-cli.js";
|
||||
export * from "./src/message-actions.js";
|
||||
export * from "./src/monitor.js";
|
||||
export * from "./src/normalize.js";
|
||||
export * from "./src/outbound-session.js";
|
||||
export * from "./src/probe.js";
|
||||
export * from "./src/reaction-level.js";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./src/install-signal-cli.js";
|
||||
export * from "./src/normalize.js";
|
||||
export { isSignalSenderAllowed, type SignalSender } from "./src/identity.js";
|
||||
@@ -2,9 +2,9 @@ import {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-targets";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
import { looksLikeUuid } from "./uuid.js";
|
||||
|
||||
function normalizeSignalApproverId(value: string | number): string | undefined {
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
attachChannelToResults,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
|
||||
import {
|
||||
looksLikeSignalTargetId,
|
||||
normalizeSignalMessagingTarget,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
|
||||
@@ -24,7 +28,6 @@ import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"
|
||||
import { signalApprovalAuth } from "./approval-auth.js";
|
||||
import { markdownToSignalTextChunks } from "./format.js";
|
||||
import { signalMessageActions } from "./message-actions.js";
|
||||
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
import { resolveSignalOutboundTarget } from "./outbound-session.js";
|
||||
import { resolveSignalReactionLevel } from "./reaction-level.js";
|
||||
import { signalSetupAdapter } from "./setup-core.js";
|
||||
|
||||
@@ -52,9 +52,9 @@ describe("signalMessageActions", () => {
|
||||
it("honors account-scoped reaction gates during discovery", () => {
|
||||
const cfg = createSignalAccountOverrideCfg();
|
||||
|
||||
expect(signalMessageActions.describeMessageTool?.({ cfg, accountId: "default" })?.actions).toEqual(
|
||||
["send"],
|
||||
);
|
||||
expect(
|
||||
signalMessageActions.describeMessageTool?.({ cfg, accountId: "default" })?.actions,
|
||||
).toEqual(["send"]);
|
||||
expect(signalMessageActions.describeMessageTool?.({ cfg, accountId: "work" })?.actions).toEqual(
|
||||
["send", "react"],
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveMentionGatingWithBypass,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-targets";
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
|
||||
import {
|
||||
@@ -56,7 +57,6 @@ import {
|
||||
resolveSignalSender,
|
||||
type SignalSender,
|
||||
} from "../identity.js";
|
||||
import { normalizeSignalMessagingTarget } from "../normalize.js";
|
||||
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
|
||||
import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js";
|
||||
import type {
|
||||
|
||||
@@ -23,7 +23,7 @@ export {
|
||||
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
|
||||
export { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
export { chunkText } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export { detectBinary } from "openclaw/plugin-sdk/setup-tools";
|
||||
export { detectBinary, installSignalCli } from "openclaw/plugin-sdk/setup-tools";
|
||||
export {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
@@ -35,7 +35,10 @@ export {
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
export { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
|
||||
export { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
export {
|
||||
looksLikeSignalTargetId,
|
||||
normalizeSignalMessagingTarget,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
export {
|
||||
listEnabledSignalAccounts,
|
||||
listSignalAccountIds,
|
||||
@@ -43,7 +46,6 @@ export {
|
||||
resolveSignalAccount,
|
||||
} from "./accounts.js";
|
||||
export { monitorSignalProvider } from "./monitor.js";
|
||||
export { installSignalCli } from "./install-signal-cli.js";
|
||||
export { probeSignal } from "./probe.js";
|
||||
export { resolveSignalReactionLevel } from "./reaction-level.js";
|
||||
export { removeReactionSignal, sendReactionSignal } from "./send-reactions.js";
|
||||
|
||||
@@ -140,7 +140,11 @@ export const signalDmPolicy = {
|
||||
getCurrent: (cfg: OpenClawConfig, accountId?: string) =>
|
||||
resolveSignalAccount({ cfg, accountId: accountId ?? resolveDefaultSignalAccountId(cfg) }).config
|
||||
.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg: OpenClawConfig, policy: "pairing" | "allowlist" | "open" | "disabled", accountId?: string) =>
|
||||
setPolicy: (
|
||||
cfg: OpenClawConfig,
|
||||
policy: "pairing" | "allowlist" | "open" | "disabled",
|
||||
accountId?: string,
|
||||
) =>
|
||||
patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel,
|
||||
|
||||
@@ -3,9 +3,8 @@ import {
|
||||
setSetupChannelEnabled,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { detectBinary, installSignalCli } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js";
|
||||
import { installSignalCli } from "./install-signal-cli.js";
|
||||
import {
|
||||
createSignalCliPathTextInput,
|
||||
normalizeSignalAccountInput,
|
||||
@@ -29,13 +28,7 @@ export const signalSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredHint: "signal-cli missing",
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 0,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
accountId
|
||||
? resolveSignalAccount({ cfg, accountId }).configured
|
||||
: listSignalAccountIds(cfg).some(
|
||||
(resolvedAccountId) =>
|
||||
resolveSignalAccount({ cfg, accountId: resolvedAccountId }).configured,
|
||||
),
|
||||
resolveConfigured: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }).configured,
|
||||
resolveBinaryPath: ({ cfg, accountId }) =>
|
||||
resolveSignalAccount({ cfg, accountId }).config.cliPath ?? "signal-cli",
|
||||
detectBinary,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
export { createSlackOutboundPayloadHarness } from "./src/outbound-payload-harness.js";
|
||||
export type {
|
||||
SlackInteractiveHandlerContext,
|
||||
SlackInteractiveHandlerRegistration,
|
||||
} from "./src/interactive-dispatch.js";
|
||||
export { collectSlackSecurityAuditFindings } from "./src/security-audit.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user