Compare commits

..

8 Commits

Author SHA1 Message Date
Peter Steinberger
e82896799c style: format control ui chat view 2026-04-04 12:11:19 +09:00
Peter Steinberger
d59a5e4da8 style: format channel extension helpers 2026-04-04 12:11:06 +09:00
Peter Steinberger
2103737a6a style: format core runtime helpers 2026-04-04 12:10:43 +09:00
Peter Steinberger
4aedf872c0 refactor: simplify marketplace root context 2026-04-04 12:07:24 +09:00
Agustin Rivera
1a2f9f465a fix(marketplace): rerun ci 2026-04-03 22:21:32 +00:00
Agustin Rivera
8bef2d2fed fix(marketplace): narrow canonical path checks 2026-04-03 22:11:31 +00:00
Agustin Rivera
399c9ad895 fix(marketplace): preserve local symlink installs 2026-04-03 22:11:31 +00:00
Agustin Rivera
0960a0ab95 fix(marketplace): canonicalize remote plugin paths 2026-04-03 22:11:31 +00:00
417 changed files with 9143 additions and 9144 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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:

View File

@@ -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`.

View File

@@ -1,8 +0,0 @@
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
createAnthropicServiceTierWrapper,
resolveAnthropicBetas,
resolveAnthropicFastMode,
resolveAnthropicServiceTier,
} from "./stream-wrappers.js";

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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";

View File

@@ -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,

View File

@@ -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",

View File

@@ -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"]),

View File

@@ -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,
};
}

View File

@@ -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({

View File

@@ -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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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?.(

View File

@@ -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";
}

View File

@@ -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.",
});
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -1,3 +0,0 @@
export function deriveLegacySessionChatType(sessionKey: string): "channel" | undefined {
return /^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(sessionKey) ? "channel" : undefined;
}

View File

@@ -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";

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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 [];
}

View File

@@ -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>>;

View File

@@ -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.",
});
}

View File

@@ -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.",
},
];
}

View File

@@ -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,

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -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),

View File

@@ -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;
},
});

View File

@@ -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.",
});
}
}

View File

@@ -82,9 +82,7 @@ const googlechatDmPolicy: ChannelSetupDmPolicy = {
dm: {
...currentDm,
policy,
...(policy === "open"
? { allowFrom: addWildcardAllowFrom(currentDm?.allowFrom) }
: {}),
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(currentDm?.allowFrom) } : {}),
},
},
});

View File

@@ -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 () => {

View File

@@ -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";

View File

@@ -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";

View File

@@ -135,9 +135,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
resolveRequireMention: resolveIMessageGroupRequireMention,
resolveToolPolicy: resolveIMessageGroupToolPolicy,
},
doctor: {
groupAllowFromFallbackToAllowFrom: false,
},
conversationBindings: {
supportsCurrentConversationBinding: true,
createManager: ({ cfg, accountId }) =>

View File

@@ -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,
);
}

View File

@@ -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";

View File

@@ -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);
});
});

View File

@@ -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 });

View File

@@ -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;
},
});

View File

@@ -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";

View File

@@ -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 {

View File

@@ -1,5 +0,0 @@
export {
listLineAccountIds,
resolveDefaultLineAccountId,
resolveLineAccount,
} from "./src/accounts.js";

View File

@@ -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";

View File

@@ -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 =

View File

@@ -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();

View File

@@ -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";

View File

@@ -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";

View File

@@ -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[] = [];

View File

@@ -1 +0,0 @@
export * from "./src/runtime-heavy-api.js";

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -6,4 +6,4 @@ export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "./runtime-heavy-api.js";
} from "openclaw/plugin-sdk/matrix-runtime-heavy";

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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,
}) ?? ""
);
}

View File

@@ -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]",
}),
}),
);
});
});

View File

@@ -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();

View File

@@ -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"));

View File

@@ -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) {

View 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";

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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);
});
});
});

View File

@@ -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.

View File

@@ -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";

View File

@@ -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;
},
});
}
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -1,4 +0,0 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -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";

View File

@@ -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({

View File

@@ -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,
},
],
});

View File

@@ -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);

View File

@@ -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]);

View File

@@ -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.
*/

View File

@@ -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.",
});
}

View File

@@ -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: {

View File

@@ -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,
},
],
});

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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";

View File

@@ -1,3 +0,0 @@
export * from "./src/install-signal-cli.js";
export * from "./src/normalize.js";
export { isSignalSenderAllowed, type SignalSender } from "./src/identity.js";

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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"],
);

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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