Merge branch 'main' into fix/token-usage-input-output-breakdown

This commit is contained in:
jiarung 2026-03-17 12:10:58 +08:00 committed by GitHub
commit aab1ed7b1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
293 changed files with 6903 additions and 5886 deletions

View File

@ -42,10 +42,13 @@ pnpm test:parallels:macos \
## Notes
- Snapshot target: closest to `macOS 26.3.1 fresh`.
- Snapshot resolver now prefers matching `*-poweroff*` clones when the base hint also matches. That lets the harness reuse disk-only recovery snapshots without passing a longer hint.
- If Windows/Linux snapshot restore logs show `PET_QUESTION_SNAPSHOT_STATE_INCOMPATIBLE_CPU`, drop the suspended state once, create a `*-poweroff*` replacement snapshot, and rerun. The smoke scripts now auto-start restored power-off snapshots.
- Harness configures Discord inside the guest; no checked-in token/config.
- Use the `openclaw` wrapper for guest `message send/read`; `node openclaw.mjs message ...` does not expose the lazy message subcommands the same way.
- Write `channels.discord.guilds` in one JSON object (`--strict-json`), not dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated like array indexes.
- Avoid `prlctl enter` / expect for long Discord setup scripts; it line-wraps/corrupts long commands. Use `prlctl exec --current-user /bin/sh -lc ...` for the Discord config phase.
- Full 3-OS sweeps: the shared build lock is safe in parallel, but snapshot restore is still a Parallels bottleneck. Prefer serialized Windows/Linux restore-heavy reruns if the host is already under load.
- Harness cleanup deletes the temporary Discord smoke messages at exit.
- Per-phase logs: `/tmp/openclaw-parallels-smoke.*`
- Machine summary: pass `--json`

View File

@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman.
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
- Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux.
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman.
- Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire.
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. (#47413) Thanks @vincentkoc.

View File

@ -362,7 +362,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local
## Docker runners (optional “works in Linux” checks)
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount CLI auth homes like `~/.codex`, `~/.claude`, `~/.qwen`, and `~/.minimax` when present so external-CLI OAuth stays available in-container:
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount CLI auth homes like `~/.codex`, `~/.claude`, `~/.qwen`, and `~/.minimax` when present, then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
- Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`)
- Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`)
@ -373,6 +373,9 @@ These run `pnpm test:live` inside the repo Docker image, mounting your local con
The live-model Docker runners also bind-mount the current checkout read-only and
stage it into a temporary workdir inside the container. This keeps the runtime
image slim while still running Vitest against your exact local source/config.
`test:docker:live-models` still runs `pnpm test:live`, so pass through
`OPENCLAW_LIVE_GATEWAY_*` as well when you need to narrow or exclude gateway
live coverage from that Docker lane.
Manual ACP plain-language thread smoke (not CI):
@ -384,8 +387,9 @@ Useful env vars:
- `OPENCLAW_CONFIG_DIR=...` (default: `~/.openclaw`) mounted to `/home/node/.openclaw`
- `OPENCLAW_WORKSPACE_DIR=...` (default: `~/.openclaw/workspace`) mounted to `/home/node/.openclaw/workspace`
- `OPENCLAW_PROFILE_FILE=...` (default: `~/.profile`) mounted to `/home/node/.profile` and sourced before running tests
- External CLI auth dirs under `$HOME` (`.codex`, `.claude`, `.qwen`, `.minimax`) are mounted read-only to the matching `/home/node/...` paths when present
- External CLI auth dirs under `$HOME` (`.codex`, `.claude`, `.qwen`, `.minimax`) are mounted read-only under `/host-auth/...`, then copied into `/home/node/...` before tests start
- `OPENCLAW_LIVE_GATEWAY_MODELS=...` / `OPENCLAW_LIVE_MODELS=...` to narrow the run
- `OPENCLAW_LIVE_GATEWAY_PROVIDERS=...` / `OPENCLAW_LIVE_PROVIDERS=...` to filter providers in-container
- `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not env)
## Docs sanity

View File

@ -10,6 +10,10 @@ title: "Media Understanding"
OpenClaw can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It autodetects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual.
Vendor-specific media behavior is registered by vendor plugins, while OpenClaw
core owns the shared `tools.media` config, fallback order, and reply-pipeline
integration.
## Goals
- Optional: predigest inbound media into short text for faster routing + better command parsing.
@ -184,7 +188,10 @@ If you set `capabilities`, the entry only runs for those media types. For shared
lists, OpenClaw can infer defaults:
- `openai`, `anthropic`, `minimax`: **image**
- `moonshot`: **image + video**
- `google` (Gemini API): **image + audio + video**
- `mistral`: **audio**
- `zai`: **image**
- `groq`: **audio**
- `deepgram`: **audio**
@ -193,11 +200,11 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
## Provider support matrix (OpenClaw integrations)
| Capability | Provider integration | Notes |
| ---------- | ------------------------------------------------ | --------------------------------------------------------- |
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). |
| Video | Google (Gemini API) | Provider video understanding. |
| Capability | Provider integration | Notes |
| ---------- | -------------------------------------------------- | ----------------------------------------------------------------------- |
| Image | OpenAI, Anthropic, Google, MiniMax, Moonshot, Z.AI | Vendor plugins register image support against core media understanding. |
| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). |
| Video | Google, Moonshot | Provider video understanding via vendor plugins. |
## Model selection guidance

View File

@ -113,9 +113,11 @@ That means:
Examples:
- the bundled `openai` plugin owns OpenAI model-provider behavior and OpenAI
speech behavior
speech + media-understanding behavior
- the bundled `elevenlabs` plugin owns ElevenLabs speech behavior
- the bundled `microsoft` plugin owns Microsoft speech behavior
- the bundled `google`, `minimax`, `mistral`, `moonshot`, and `zai` plugins own
their media-understanding backends
- the `voice-call` plugin is a feature plugin: it owns call transport, tools,
CLI, routes, and runtime, but it consumes core TTS/STT capability instead of
inventing a second speech stack
@ -167,6 +169,24 @@ For example, TTS follows this shape:
That same pattern should be preferred for future capabilities.
### Capability example: video understanding
OpenClaw already treats image/audio/video understanding as one shared
capability. The same ownership model applies there:
1. core defines the media-understanding contract
2. vendor plugins register `describeImage`, `transcribeAudio`, and
`describeVideo` as applicable
3. channels and feature plugins consume the shared core behavior instead of
wiring directly to vendor code
That avoids baking one provider's video assumptions into core. The plugin owns
the vendor surface; core owns the capability contract and fallback behavior.
If OpenClaw adds a new domain later, such as video generation, use the same
sequence again: define the core capability first, then let vendor plugins
register implementations against it.
## Compatible bundles
OpenClaw also recognizes two compatible external bundle layouts:
@ -331,7 +351,8 @@ There are two layers of enforcement:
2. **contract tests**
Bundled plugins are captured in contract registries during test runs so
OpenClaw can assert ownership explicitly. Today this is used for model
providers, web search providers, and bundled registration ownership.
providers, speech providers, web search providers, and bundled registration
ownership.
The practical effect is that OpenClaw knows, up front, which plugin owns which
surface. That lets core and channels compose seamlessly because ownership is
@ -649,19 +670,32 @@ to think of as short-lived performance caches, not persistence.
## Runtime helpers
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
Plugins can access selected core helpers via `api.runtime`. For TTS:
```ts
const clip = await api.runtime.tts.textToSpeech({
text: "Hello from OpenClaw",
cfg: api.config,
});
const result = await api.runtime.tts.textToSpeechTelephony({
text: "Hello from OpenClaw",
cfg: api.config,
});
const voices = await api.runtime.tts.listVoices({
provider: "elevenlabs",
cfg: api.config,
});
```
Notes:
- `textToSpeech` returns the normal core TTS output payload for file/voice-note surfaces.
- Uses core `messages.tts` configuration and provider selection.
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
- `listVoices` is optional per provider. Use it for vendor-owned voice pickers or setup flows.
- Voice listings can include richer metadata such as locale, gender, and personality tags for provider-aware pickers.
- OpenAI and ElevenLabs support telephony today. Microsoft does not.
Plugins can also register speech providers via `api.registerSpeechProvider(...)`.
@ -691,10 +725,48 @@ Notes:
text, speech, image, and future media providers as OpenClaw adds those
capability contracts.
For STT/transcription, plugins can call:
For image/audio/video understanding, plugins register one typed
media-understanding provider instead of a generic key/value bag:
```ts
const { text } = await api.runtime.stt.transcribeAudioFile({
api.registerMediaUnderstandingProvider({
id: "google",
capabilities: ["image", "audio", "video"],
describeImage: async (req) => ({ text: "..." }),
transcribeAudio: async (req) => ({ text: "..." }),
describeVideo: async (req) => ({ text: "..." }),
});
```
Notes:
- Keep orchestration, fallback, config, and channel wiring in core.
- Keep vendor behavior in the provider plugin.
- Additive expansion should stay typed: new optional methods, new optional
result fields, new optional capabilities.
- If OpenClaw adds a new capability such as video generation later, define the
core capability contract first, then let vendor plugins register against it.
For media-understanding runtime helpers, plugins can call:
```ts
const image = await api.runtime.mediaUnderstanding.describeImageFile({
filePath: "/tmp/inbound-photo.jpg",
cfg: api.config,
agentDir: "/tmp/agent",
});
const video = await api.runtime.mediaUnderstanding.describeVideoFile({
filePath: "/tmp/inbound-video.mp4",
cfg: api.config,
});
```
For audio transcription, plugins can use either the media-understanding runtime
or the older STT alias:
```ts
const { text } = await api.runtime.mediaUnderstanding.transcribeAudioFile({
filePath: "/tmp/inbound-audio.ogg",
cfg: api.config,
// Optional when MIME cannot be inferred reliably:
@ -704,8 +776,11 @@ const { text } = await api.runtime.stt.transcribeAudioFile({
Notes:
- `api.runtime.mediaUnderstanding.*` is the preferred shared surface for
image/audio/video understanding.
- Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order.
- Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input).
- `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias.
## Gateway HTTP routes
@ -1268,6 +1343,7 @@ Plugins export either:
- `registerChannel`
- `registerProvider`
- `registerSpeechProvider`
- `registerMediaUnderstandingProvider`
- `registerWebSearchProvider`
- `registerHttpRoute`
- `registerCommand`

View File

@ -26,6 +26,7 @@ import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import type { ProviderAuthResult } from "../../src/plugins/types.js";
import { normalizeSecretInput } from "../../src/utils/normalize-secret-input.js";
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
const PROVIDER_ID = "anthropic";
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6";
@ -394,6 +395,7 @@ const anthropicPlugin = {
profileId: ctx.profileId,
}),
});
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
},
};

View File

@ -0,0 +1,8 @@
import { describeImageWithModel } from "../../src/media-understanding/providers/image.js";
import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js";
export const anthropicMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "anthropic",
capabilities: ["image"],
describeImage: describeImageWithModel,
};

View File

@ -1,13 +1,12 @@
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
patchScopedAccountConfig,
prepareScopedSetupConfig,
} from "../../../src/channels/plugins/setup-helpers.js";
import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/setup-wizard-helpers.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { normalizeAccountId } from "../../../src/routing/session-key.js";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
const channel = "bluebubbles" as const;
@ -38,7 +37,7 @@ export function setBlueBubblesAllowFrom(
export const blueBubblesSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
prepareScopedSetupConfig({
cfg,
channelKey: channel,
accountId,
@ -57,19 +56,13 @@ export const blueBubblesSetupAdapter: ChannelSetupAdapter = {
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
const next = prepareScopedSetupConfig({
cfg,
channelKey: channel,
accountId,
name: input.name,
migrateBaseName: true,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
return applyBlueBubblesConnectionConfig({
cfg: next,
accountId,

View File

@ -1,14 +1,14 @@
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
resolveSetupAccountId,
} from "../../../src/channels/plugins/setup-wizard-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import {
listBlueBubblesAccountIds,
resolveBlueBubblesAccount,

View File

@ -1,6 +1,7 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { ensureModelAllowlistEntry } from "../../src/commands/model-allowlist.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { buildPairedProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js";
import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js";
const PROVIDER_ID = "byteplus";
@ -45,18 +46,15 @@ const byteplusPlugin = {
],
catalog: {
order: "paired",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
return {
providers: {
byteplus: { ...buildBytePlusProvider(), apiKey },
"byteplus-plan": { ...buildBytePlusCodingProvider(), apiKey },
},
};
},
run: (ctx) =>
buildPairedProviderApiKeyCatalog({
ctx,
providerId: PROVIDER_ID,
buildProviders: () => ({
byteplus: buildBytePlusProvider(),
"byteplus-plan": buildBytePlusCodingProvider(),
}),
}),
},
});
},

View File

@ -11,15 +11,16 @@ import {
validateApiKeyInput,
} from "../../src/commands/auth-choice.api-key.js";
import { ensureApiKeyFromOptionEnvOrPrompt } from "../../src/commands/auth-choice.apply-helpers.js";
import { buildApiKeyCredential } from "../../src/commands/onboard-auth.credentials.js";
import {
applyCloudflareAiGatewayConfig,
applyAuthProfileConfig,
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
} from "../../src/commands/onboard-auth.js";
import { buildApiKeyCredential } from "../../src/commands/auth-credentials.js";
import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js";
import type { SecretInput } from "../../src/config/types.secrets.js";
import { coerceSecretRef } from "../../src/config/types.secrets.js";
import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js";
import {
applyCloudflareAiGatewayConfig,
buildCloudflareAiGatewayConfigPatch,
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
} from "./onboard.js";
const PROVIDER_ID = "cloudflare-ai-gateway";
const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY";
@ -53,30 +54,6 @@ function resolveMetadataFromCredential(
};
}
function buildCloudflareConfigPatch(params: { accountId: string; gatewayId: string }) {
const baseUrl = resolveCloudflareAiGatewayBaseUrl(params);
return {
models: {
providers: {
[PROVIDER_ID]: {
baseUrl,
api: "anthropic-messages" as const,
models: [buildCloudflareAiGatewayModelDefinition()],
},
},
},
agents: {
defaults: {
models: {
[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]: {
alias: "Cloudflare AI Gateway",
},
},
},
},
};
}
async function resolveCloudflareGatewayMetadataInteractive(ctx: {
accountId?: string;
gatewayId?: string;
@ -180,7 +157,7 @@ const cloudflareAiGatewayPlugin = {
),
},
],
configPatch: buildCloudflareConfigPatch(metadata),
configPatch: buildCloudflareAiGatewayConfigPatch(metadata),
defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
};
},

View File

@ -0,0 +1,93 @@
import {
buildCloudflareAiGatewayModelDefinition,
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
resolveCloudflareAiGatewayBaseUrl,
} from "../../src/agents/cloudflare-ai-gateway.js";
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithDefaultModel,
} from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF };
export function buildCloudflareAiGatewayConfigPatch(params: {
accountId: string;
gatewayId: string;
}) {
const baseUrl = resolveCloudflareAiGatewayBaseUrl(params);
return {
models: {
providers: {
"cloudflare-ai-gateway": {
baseUrl,
api: "anthropic-messages" as const,
models: [buildCloudflareAiGatewayModelDefinition()],
},
},
},
agents: {
defaults: {
models: {
[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]: {
alias: "Cloudflare AI Gateway",
},
},
},
},
};
}
export function applyCloudflareAiGatewayProviderConfig(
cfg: OpenClawConfig,
params?: { accountId?: string; gatewayId?: string },
): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF] = {
...models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF],
alias: models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Cloudflare AI Gateway",
};
const existingProvider = cfg.models?.providers?.["cloudflare-ai-gateway"] as
| { baseUrl?: unknown }
| undefined;
const baseUrl =
params?.accountId && params?.gatewayId
? resolveCloudflareAiGatewayBaseUrl({
accountId: params.accountId,
gatewayId: params.gatewayId,
})
: typeof existingProvider?.baseUrl === "string"
? existingProvider.baseUrl
: undefined;
if (!baseUrl) {
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
return applyProviderConfigWithDefaultModel(cfg, {
agentModels: models,
providerId: "cloudflare-ai-gateway",
api: "anthropic-messages",
baseUrl,
defaultModel: buildCloudflareAiGatewayModelDefinition(),
});
}
export function applyCloudflareAiGatewayConfig(
cfg: OpenClawConfig,
params?: { accountId?: string; gatewayId?: string },
): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyCloudflareAiGatewayProviderConfig(cfg, params),
CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF,
);
}

View File

@ -1,13 +1,13 @@
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../../../src/config/types.secrets.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type OpenClawConfig,
type DiscordAccountConfig,
} from "openclaw/plugin-sdk/discord";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../../../src/config/types.secrets.js";
} from "../../../src/plugin-sdk-internal/discord.js";
import {
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,

View File

@ -1,14 +1,14 @@
import type {
OpenClawConfig,
DiscordAccountConfig,
DiscordActionConfig,
} from "openclaw/plugin-sdk/discord";
import {
createAccountActionGate,
createAccountListHelpers,
normalizeAccountId,
resolveAccountEntry,
} from "../../../src/plugin-sdk-internal/accounts.js";
import type {
OpenClawConfig,
DiscordAccountConfig,
DiscordActionConfig,
} from "../../../src/plugin-sdk-internal/discord.js";
import { resolveDiscordToken } from "./token.js";
export type ResolvedDiscordAccount = {

View File

@ -1,4 +1,5 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import {
readNumberParam,
readStringArrayParam,
@ -9,7 +10,6 @@ import { handleDiscordAction } from "../../../../src/agents/tools/discord-action
import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js";
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
import { normalizeInteractiveReply } from "../../../../src/interactive/payload.js";
import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js";
import { buildDiscordInteractiveComponents } from "../shared-interactive.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";

View File

@ -1,77 +1,8 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
createScopedAccountConfigAccessors,
formatAllowFromLowercase,
} from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
DiscordConfigSchema,
getChatChannelMeta,
type ChannelPlugin,
} from "openclaw/plugin-sdk/discord";
import { inspectDiscordAccount } from "./account-inspect.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js";
import type { ChannelPlugin } from "openclaw/plugin-sdk/discord";
import type { ResolvedDiscordAccount } from "./accounts.js";
import { discordSetupAdapter } from "./setup-core.js";
import { createDiscordPluginBase } from "./shared.js";
async function loadDiscordChannelRuntime() {
return await import("./channel.runtime.js");
}
const discordConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
const discordConfigBase = createScopedChannelConfigBase({
sectionKey: "discord",
listAccountIds: listDiscordAccountIds,
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultDiscordAccountId,
clearBaseFields: ["token", "name"],
});
const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({
discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard,
}));
export const discordSetupPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
id: "discord",
meta: {
...getChatChannelMeta("discord"),
},
setupWizard: discordSetupWizard,
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
...discordConfigBase,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
...discordConfigAccessors,
},
export const discordSetupPlugin: ChannelPlugin<ResolvedDiscordAccount> = createDiscordPluginBase({
setup: discordSetupAdapter,
};
});

View File

@ -1,12 +1,9 @@
import { Separator, TextDisplay } from "@buape/carbon";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedAllowlistConfigEditor,
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,
collectOpenGroupPolicyConfiguredRouteWarnings,
createScopedAccountConfigAccessors,
formatAllowFromLowercase,
} from "openclaw/plugin-sdk/compat";
import {
buildAgentSessionKey,
@ -15,11 +12,8 @@ import {
} from "openclaw/plugin-sdk/core";
import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
DEFAULT_ACCOUNT_ID,
DiscordConfigSchema,
getChatChannelMeta,
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
PAIRING_APPROVED_MESSAGE,
@ -32,12 +26,11 @@ import {
type OpenClawConfig,
} from "openclaw/plugin-sdk/discord";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js";
import { normalizeMessageChannel } from "../../../src/utils/message-channel.js";
import { inspectDiscordAccount } from "./account-inspect.js";
import {
listDiscordAccountIds,
resolveDiscordAccount,
resolveDefaultDiscordAccountId,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { collectDiscordAuditChannelIds } from "./audit.js";
@ -54,7 +47,8 @@ import type { DiscordProbe } from "./probe.js";
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
import { getDiscordRuntime } from "./runtime.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js";
import { discordSetupAdapter } from "./setup-core.js";
import { createDiscordPluginBase, discordConfigAccessors } from "./shared.js";
import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./targets.js";
import { DiscordUiContainer } from "./ui.js";
@ -63,13 +57,8 @@ type DiscordSendFn = ReturnType<
typeof getDiscordRuntime
>["channel"]["discord"]["sendMessageDiscord"];
const meta = getChatChannelMeta("discord");
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
async function loadDiscordChannelRuntime() {
return await import("./channel.runtime.js");
}
function formatDiscordIntents(intents?: {
messageContent?: string;
guildMembers?: string;
@ -208,20 +197,6 @@ function parseDiscordExplicitTarget(raw: string) {
}
}
function normalizeOutboundThreadId(value?: string | number | null): string | undefined {
if (value == null) {
return undefined;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
return undefined;
}
return String(Math.trunc(value));
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function buildDiscordBaseSessionKey(params: {
cfg: OpenClawConfig;
agentId: string;
@ -304,32 +279,10 @@ function resolveDiscordOutboundSessionRoute(params: {
};
}
const discordConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
const discordConfigBase = createScopedChannelConfigBase({
sectionKey: "discord",
listAccountIds: listDiscordAccountIds,
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultDiscordAccountId,
clearBaseFields: ["token", "name"],
});
const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({
discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard,
}));
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
id: "discord",
meta: {
...meta,
},
setupWizard: discordSetupWizard,
...createDiscordPluginBase({
setup: discordSetupAdapter,
}),
pairing: {
idLabel: "discordUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
@ -340,31 +293,6 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
);
},
},
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
...discordConfigBase,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
...discordConfigAccessors,
},
allowlist: {
supportsScope: ({ scope }) => scope === "dm",
readConfig: ({ cfg, accountId }) =>

View File

@ -1,4 +1,5 @@
import type { Guild, User } from "@buape/carbon";
import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js";
import {
buildChannelKeyCandidates,
@ -6,7 +7,6 @@ import {
resolveChannelMatchConfig,
type ChannelMatchSource,
} from "../../../../src/channels/channel-config.js";
import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js";
import { formatDiscordUserTag } from "./format.js";
export type DiscordAllowList = {

View File

@ -1,7 +1,7 @@
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { createRunStateMachine } from "../../../../src/channels/run-state-machine.js";
import { danger } from "../../../../src/globals.js";
import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts";
import { KeyedAsyncQueue } from "../../../../src/plugin-sdk/keyed-async-queue.js";
import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js";
import type { RuntimeEnv } from "./message-handler.preflight.types.js";
import { processDiscordMessage } from "./message-handler.process.js";

View File

@ -1,14 +1,11 @@
import os from "node:os";
import path from "node:path";
import { normalizeAccountId as normalizeSharedAccountId } from "openclaw/plugin-sdk/account-id";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { normalizeProviderId } from "../../../../src/agents/model-selection.js";
import { resolveStateDir } from "../../../../src/config/paths.js";
import { withFileLock } from "../../../../src/infra/file-lock.js";
import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js";
import {
readJsonFileWithFallback,
writeJsonFileAtomically,
} from "../../../../src/plugin-sdk/json-store.js";
import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js";
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {
retries: {

View File

@ -212,6 +212,58 @@ describe("Discord native plugin command dispatch", () => {
);
});
it("round-trips Discord native aliases through the real plugin registry", async () => {
const cfg = createConfig();
const commandSpec: NativeCommandSpec = {
name: "pairdiscord",
description: "Pair",
acceptsArgs: true,
};
const command = createDiscordNativeCommand({
command: commandSpec,
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const interaction = createInteraction();
expect(
registerPluginCommand("demo-plugin", {
name: "pair",
nativeNames: {
telegram: "pair_device",
discord: "pairdiscord",
},
description: "Pair device",
acceptsArgs: true,
requireAuth: false,
handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }),
}),
).toEqual({ ok: true });
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
await (command as { run: (interaction: unknown) => Promise<void> }).run(
Object.assign(interaction, {
options: {
getString: () => "now",
getBoolean: () => null,
getFocused: () => "",
},
}) as unknown,
);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(
expect.objectContaining({ content: "paired:now" }),
);
});
it("blocks unauthorized Discord senders before requireAuth:false plugin commands execute", async () => {
const cfg = {
commands: {

View File

@ -0,0 +1,39 @@
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
formatAllowFromLowercase,
} from "../../../src/plugin-sdk-internal/channel-config.js";
import { type OpenClawConfig } from "../../../src/plugin-sdk-internal/discord.js";
import { inspectDiscordAccount } from "./account-inspect.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { createDiscordSetupWizardProxy } from "./setup-core.js";
async function loadDiscordChannelRuntime() {
return await import("./channel.runtime.js");
}
export const discordConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) =>
resolveDiscordAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
export const discordConfigBase = createScopedChannelConfigBase({
sectionKey: "discord",
listAccountIds: listDiscordAccountIds,
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultDiscordAccountId,
clearBaseFields: ["token", "name"],
});
export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({
discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard,
}));

View File

@ -1,5 +1,7 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import {
createPluginRuntimeStore,
type PluginRuntime,
} from "../../../src/plugin-sdk-internal/core.js";
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");

View File

@ -1,10 +1,7 @@
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
import type { DiscordGuildEntry } from "../../../src/config/types.discord.js";
import {
applyAccountNameToChannelSection,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
noteChannelLookupFailure,
noteChannelLookupSummary,
parseMentionOrPrefixedId,
@ -18,6 +15,7 @@ import {
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "../../../src/plugin-sdk-internal/setup.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { inspectDiscordAccount } from "./account-inspect.js";
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
@ -72,15 +70,8 @@ export function parseDiscordAllowFromId(value: string): string | null {
});
}
export const discordSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
export const discordSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: channel,
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "DISCORD_BOT_TOKEN can only be used for the default account.";
@ -90,57 +81,46 @@ export const discordSetupAdapter: ChannelSetupAdapter = {
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
discord: {
...next.channels?.discord,
enabled: true,
accounts: {
...next.channels?.discord?.accounts,
[accountId]: {
...next.channels?.discord?.accounts?.[accountId],
enabled: true,
...(input.token ? { token: input.token } : {}),
},
},
},
},
};
},
buildPatch: (input) => (input.useEnv ? {} : input.token ? { token: input.token } : {}),
});
type DiscordAllowFromResolverParams = {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { token?: string };
entries: string[];
};
export function createDiscordSetupWizardProxy(
loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>,
) {
type DiscordGroupAllowlistResolverParams = DiscordAllowFromResolverParams & {
prompter: { note: (message: string, title?: string) => Promise<void> };
};
type DiscordGroupAllowlistResolution = Array<{
input: string;
resolved: boolean;
}>;
type DiscordSetupWizardHandlers = {
promptAllowFrom: (params: {
cfg: OpenClawConfig;
prompter: import("../../../src/plugin-sdk-internal/setup.js").WizardPrompter;
accountId?: string;
}) => Promise<OpenClawConfig>;
resolveAllowFromEntries: (params: DiscordAllowFromResolverParams) => Promise<
Array<{
input: string;
resolved: boolean;
id: string | null;
}>
>;
resolveGroupAllowlist: (
params: DiscordGroupAllowlistResolverParams,
) => Promise<DiscordGroupAllowlistResolution>;
};
export function createDiscordSetupWizardBase(
handlers: DiscordSetupWizardHandlers,
): ChannelSetupWizard {
const discordDmPolicy: ChannelSetupDmPolicy = {
label: "Discord",
channel,
@ -154,13 +134,7 @@ export function createDiscordSetupWizardProxy(
channel,
dmPolicy: policy,
}),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.dmPolicy?.promptAllowFrom) {
return cfg;
}
return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId });
},
promptAllowFrom: handlers.promptAllowFrom,
};
return {
@ -238,44 +212,22 @@ export function createDiscordSetupWizardProxy(
accountId,
patch: { groupPolicy: policy },
}),
resolveAllowlist: async ({
cfg,
accountId,
credentialValues,
entries,
prompter,
}: {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { token?: string };
entries: string[];
prompter: { note: (message: string, title?: string) => Promise<void> };
}) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.groupAccess?.resolveAllowlist) {
return entries.map((input) => ({ input, resolved: false }));
}
resolveAllowlist: async (params: DiscordGroupAllowlistResolverParams) => {
try {
return await wizard.groupAccess.resolveAllowlist({
cfg,
accountId,
credentialValues,
entries,
prompter,
});
return await handlers.resolveGroupAllowlist(params);
} catch (error) {
await noteChannelLookupFailure({
prompter,
prompter: params.prompter,
label: "Discord channels",
error,
});
await noteChannelLookupSummary({
prompter,
prompter: params.prompter,
label: "Discord channels",
resolvedSections: [],
unresolved: entries,
unresolved: params.entries,
});
return entries.map((input) => ({ input, resolved: false }));
return params.entries.map((input) => ({ input, resolved: false }));
}
},
applyAllowlist: ({
@ -305,28 +257,7 @@ export function createDiscordSetupWizardProxy(
invalidWithoutCredentialNote:
"Bot token missing; use numeric user ids (or mention form) only.",
parseId: parseDiscordAllowFromId,
resolveEntries: async ({
cfg,
accountId,
credentialValues,
entries,
}: {
cfg: OpenClawConfig;
accountId: string;
credentialValues: { token?: string };
entries: string[];
}) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.allowFrom) {
return entries.map((input) => ({ input, resolved: false, id: null }));
}
return await wizard.allowFrom.resolveEntries({
cfg,
accountId,
credentialValues,
entries,
});
},
resolveEntries: handlers.resolveAllowFromEntries,
apply: async ({
cfg,
accountId,
@ -347,3 +278,42 @@ export function createDiscordSetupWizardProxy(
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;
}
export function createDiscordSetupWizardProxy(
loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>,
) {
return createDiscordSetupWizardBase({
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.dmPolicy?.promptAllowFrom) {
return cfg;
}
return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId });
},
resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.allowFrom) {
return entries.map((input) => ({ input, resolved: false, id: null }));
}
return await wizard.allowFrom.resolveEntries({
cfg,
accountId,
credentialValues,
entries,
});
},
resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
const wizard = (await loadWizard()).discordSetupWizard;
if (!wizard.groupAccess?.resolveAllowlist) {
return entries.map((input) => ({ input, resolved: false }));
}
return (await wizard.groupAccess.resolveAllowlist({
cfg,
accountId,
credentialValues,
entries,
prompter,
})) as DiscordGroupAllowlistResolution;
},
});
}

View File

@ -1,27 +1,14 @@
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
noteChannelLookupFailure,
noteChannelLookupSummary,
type OpenClawConfig,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
promptLegacyChannelAllowFrom,
resolveSetupAccountId,
setLegacyChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type WizardPrompter,
} from "../../../src/plugin-sdk-internal/setup.js";
import {
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "../../../src/plugin-sdk-internal/setup.js";
import { inspectDiscordAccount } from "./account-inspect.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "./accounts.js";
import { type ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import {
resolveDiscordChannelAllowlist,
@ -29,6 +16,7 @@ import {
} from "./resolve-channels.js";
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
import {
createDiscordSetupWizardBase,
discordSetupAdapter,
DISCORD_TOKEN_HELP_LINES,
parseDiscordAllowFromId,
@ -94,186 +82,62 @@ async function promptDiscordAllowFrom(params: {
});
}
const discordDmPolicy: ChannelSetupDmPolicy = {
label: "Discord",
channel,
policyKey: "channels.discord.dmPolicy",
allowFromKey: "channels.discord.allowFrom",
getCurrent: (cfg) =>
cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) =>
setLegacyChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
export const discordSetupWizard: ChannelSetupWizard = createDiscordSetupWizardBase({
promptAllowFrom: promptDiscordAllowFrom,
};
export const discordSetupWizard: ChannelSetupWizard = {
channel,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs token",
configuredHint: "configured",
unconfiguredHint: "needs token",
configuredScore: 2,
unconfiguredScore: 1,
resolveConfigured: ({ cfg }) =>
listDiscordAccountIds(cfg).some(
(accountId) => inspectDiscordAccount({ cfg, accountId }).configured,
),
},
credentials: [
{
inputKey: "token",
providerHint: channel,
credentialLabel: "Discord bot token",
preferredEnvVar: "DISCORD_BOT_TOKEN",
helpTitle: "Discord bot token",
helpLines: DISCORD_TOKEN_HELP_LINES,
envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?",
keepPrompt: "Discord token already configured. Keep it?",
inputPrompt: "Enter Discord bot token",
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
inspect: ({ cfg, accountId }) => {
const account = inspectDiscordAccount({ cfg, accountId });
return {
accountConfigured: account.configured,
hasConfiguredValue: account.tokenStatus !== "missing",
resolvedValue: account.token?.trim() || undefined,
envValue:
accountId === DEFAULT_ACCOUNT_ID
? process.env.DISCORD_BOT_TOKEN?.trim() || undefined
: undefined,
};
},
},
],
groupAccess: {
label: "Discord channels",
placeholder: "My Server/#general, guildId/channelId, #support",
currentPolicy: ({ cfg, accountId }) =>
resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist",
currentEntries: ({ cfg, accountId }) =>
Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap(
([guildKey, value]) => {
const channels = value?.channels ?? {};
const channelKeys = Object.keys(channels);
if (channelKeys.length === 0) {
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
return [input];
}
return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`);
},
),
updatePrompt: ({ cfg, accountId }) =>
Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds),
setPolicy: ({ cfg, accountId, policy }) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { groupPolicy: policy },
}),
resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
const token =
resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) =>
await resolveDiscordAllowFromEntries({
token:
resolveDiscordAccount({ cfg, accountId }).token ||
(typeof credentialValues.token === "string" ? credentialValues.token : "");
let resolved: DiscordChannelResolution[] = entries.map((input) => ({
input,
resolved: false,
}));
if (!token || entries.length === 0) {
return resolved;
}
try {
resolved = await resolveDiscordChannelAllowlist({
token,
entries,
});
const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId);
const resolvedGuilds = resolved.filter(
(entry) => entry.resolved && entry.guildId && !entry.channelId,
);
const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input);
await noteChannelLookupSummary({
prompter,
label: "Discord channels",
resolvedSections: [
{
title: "Resolved channels",
values: resolvedChannels
.map((entry) => entry.channelId)
.filter((value): value is string => Boolean(value)),
},
{
title: "Resolved guilds",
values: resolvedGuilds
.map((entry) => entry.guildId)
.filter((value): value is string => Boolean(value)),
},
],
unresolved,
});
} catch (error) {
await noteChannelLookupFailure({
prompter,
label: "Discord channels",
error,
});
}
(typeof credentialValues.token === "string" ? credentialValues.token : ""),
entries,
}),
resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
const token =
resolveDiscordAccount({ cfg, accountId }).token ||
(typeof credentialValues.token === "string" ? credentialValues.token : "");
let resolved: DiscordChannelResolution[] = entries.map((input) => ({
input,
resolved: false,
}));
if (!token || entries.length === 0) {
return resolved;
},
applyAllowlist: ({ cfg, accountId, resolved }) => {
const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = [];
for (const entry of resolved as DiscordChannelResolution[]) {
const guildKey =
entry.guildId ??
(entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ??
"*";
const channelKey =
entry.channelId ??
(entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined);
if (!channelKey && guildKey === "*") {
continue;
}
allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) });
}
return setDiscordGuildChannelAllowlist(cfg, accountId, allowlistEntries);
},
},
allowFrom: {
credentialInputKey: "token",
helpTitle: "Discord allowlist",
helpLines: [
"Allowlist Discord DMs by username (we resolve to user ids).",
"Examples:",
"- 123456789012345678",
"- @alice",
"- alice#1234",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/discord", "discord")}`,
],
message: "Discord allowFrom (usernames or ids)",
placeholder: "@alice, 123456789012345678",
invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.",
parseId: parseDiscordAllowFromId,
resolveEntries: async ({ cfg, accountId, credentialValues, entries }) =>
await resolveDiscordAllowFromEntries({
token:
resolveDiscordAccount({ cfg, accountId }).token ||
(typeof credentialValues.token === "string" ? credentialValues.token : ""),
}
try {
resolved = await resolveDiscordChannelAllowlist({
token,
entries,
}),
apply: async ({ cfg, accountId, allowFrom }) =>
patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { dmPolicy: "allowlist", allowFrom },
}),
});
const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId);
const resolvedGuilds = resolved.filter(
(entry) => entry.resolved && entry.guildId && !entry.channelId,
);
const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input);
await noteChannelLookupSummary({
prompter,
label: "Discord channels",
resolvedSections: [
{
title: "Resolved channels",
values: resolvedChannels
.map((entry) => entry.channelId)
.filter((value): value is string => Boolean(value)),
},
{
title: "Resolved guilds",
values: resolvedGuilds
.map((entry) => entry.guildId)
.filter((value): value is string => Boolean(value)),
},
],
unresolved,
});
} catch (error) {
await noteChannelLookupFailure({
prompter,
label: "Discord channels",
error,
});
}
return resolved;
},
dmPolicy: discordDmPolicy,
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
});

View File

@ -0,0 +1,94 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
createScopedAccountConfigAccessors,
formatAllowFromLowercase,
} from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
DiscordConfigSchema,
getChatChannelMeta,
type ChannelPlugin,
} from "openclaw/plugin-sdk/discord";
import { inspectDiscordAccount } from "./account-inspect.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { createDiscordSetupWizardProxy } from "./setup-core.js";
export const DISCORD_CHANNEL = "discord" as const;
async function loadDiscordChannelRuntime() {
return await import("./channel.runtime.js");
}
export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({
discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard,
}));
export const discordConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
export const discordConfigBase = createScopedChannelConfigBase<ResolvedDiscordAccount>({
sectionKey: DISCORD_CHANNEL,
listAccountIds: listDiscordAccountIds,
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultDiscordAccountId,
clearBaseFields: ["token", "name"],
});
export function createDiscordPluginBase(params: {
setup: NonNullable<ChannelPlugin<ResolvedDiscordAccount>["setup"]>;
}): Pick<
ChannelPlugin<ResolvedDiscordAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "streaming"
| "reload"
| "configSchema"
| "config"
| "setup"
> {
return {
id: DISCORD_CHANNEL,
meta: {
...getChatChannelMeta(DISCORD_CHANNEL),
},
setupWizard: discordSetupWizard,
capabilities: {
chatTypes: ["direct", "channel", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
...discordConfigBase,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
...discordConfigAccessors,
},
setup: params.setup,
};
}

View File

@ -1,4 +1,4 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import type { OpenClawPluginApi } from "../../../src/plugin-sdk-internal/core.js";
import { resolveDiscordAccount } from "./accounts.js";
import {
autoBindSpawnedDiscordSubagent,

View File

@ -1,6 +1,8 @@
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import {
DEFAULT_ACCOUNT_ID,
type ChannelSetupAdapter,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import type { FeishuConfig } from "./types.js";
export function setFeishuNamedAccountEnabled(

View File

@ -1,20 +1,20 @@
import {
buildSingleChannelSecretPromptState,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
hasConfiguredSecretInput,
mergeAllowFromEntries,
promptSingleChannelSecretInput,
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
setTopLevelChannelGroupPolicy,
splitSetupEntries,
} from "../../../src/channels/plugins/setup-wizard-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import type { SecretInput } from "../../../src/config/types.secrets.js";
import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
type SecretInput,
} from "openclaw/plugin-sdk/setup";
import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js";
import { probeFeishu } from "./probe.js";
import { feishuSetupAdapter } from "./setup-core.js";

View File

@ -1,5 +1,5 @@
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/core";
import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js";
import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js";
import type {
OpenClawPluginApi,
ProviderAuthContext,

View File

@ -11,6 +11,7 @@ import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
const googlePlugin = {
@ -51,6 +52,7 @@ const googlePlugin = {
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
});
registerGoogleGeminiCliProvider(api);
api.registerMediaUnderstandingProvider(googleMediaUnderstandingProvider);
api.registerWebSearchProvider(
createPluginBackedWebSearchProvider({
id: "gemini",

View File

@ -0,0 +1,150 @@
import { normalizeGoogleModelId } from "../../src/agents/model-id-normalization.js";
import { parseGeminiAuth } from "../../src/infra/gemini-auth.js";
import { describeImageWithModel } from "../../src/media-understanding/providers/image.js";
import {
assertOkOrThrowHttpError,
normalizeBaseUrl,
postJsonRequest,
} from "../../src/media-understanding/providers/shared.js";
import type {
AudioTranscriptionRequest,
AudioTranscriptionResult,
MediaUnderstandingProvider,
VideoDescriptionRequest,
VideoDescriptionResult,
} from "../../src/media-understanding/types.js";
export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview";
const DEFAULT_GOOGLE_VIDEO_MODEL = "gemini-3-flash-preview";
const DEFAULT_GOOGLE_AUDIO_PROMPT = "Transcribe the audio.";
const DEFAULT_GOOGLE_VIDEO_PROMPT = "Describe the video.";
async function generateGeminiInlineDataText(params: {
buffer: Buffer;
mime?: string;
apiKey: string;
baseUrl?: string;
headers?: Record<string, string>;
model?: string;
prompt?: string;
timeoutMs: number;
fetchFn?: typeof fetch;
defaultBaseUrl: string;
defaultModel: string;
defaultPrompt: string;
defaultMime: string;
httpErrorLabel: string;
missingTextError: string;
}): Promise<{ text: string; model: string }> {
const fetchFn = params.fetchFn ?? fetch;
const baseUrl = normalizeBaseUrl(params.baseUrl, params.defaultBaseUrl);
const allowPrivate = Boolean(params.baseUrl?.trim());
const model = (() => {
const trimmed = params.model?.trim();
if (!trimmed) {
return params.defaultModel;
}
return normalizeGoogleModelId(trimmed);
})();
const url = `${baseUrl}/models/${model}:generateContent`;
const authHeaders = parseGeminiAuth(params.apiKey);
const headers = new Headers(params.headers);
for (const [key, value] of Object.entries(authHeaders.headers)) {
if (!headers.has(key)) {
headers.set(key, value);
}
}
const prompt = (() => {
const trimmed = params.prompt?.trim();
return trimmed || params.defaultPrompt;
})();
const body = {
contents: [
{
role: "user",
parts: [
{ text: prompt },
{
inline_data: {
mime_type: params.mime ?? params.defaultMime,
data: params.buffer.toString("base64"),
},
},
],
},
],
};
const { response: res, release } = await postJsonRequest({
url,
headers,
body,
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork: allowPrivate,
});
try {
await assertOkOrThrowHttpError(res, params.httpErrorLabel);
const payload = (await res.json()) as {
candidates?: Array<{
content?: { parts?: Array<{ text?: string }> };
}>;
};
const parts = payload.candidates?.[0]?.content?.parts ?? [];
const text = parts
.map((part) => part?.text?.trim())
.filter(Boolean)
.join("\n");
if (!text) {
throw new Error(params.missingTextError);
}
return { text, model };
} finally {
await release();
}
}
export async function transcribeGeminiAudio(
params: AudioTranscriptionRequest,
): Promise<AudioTranscriptionResult> {
const { text, model } = await generateGeminiInlineDataText({
...params,
defaultBaseUrl: DEFAULT_GOOGLE_AUDIO_BASE_URL,
defaultModel: DEFAULT_GOOGLE_AUDIO_MODEL,
defaultPrompt: DEFAULT_GOOGLE_AUDIO_PROMPT,
defaultMime: "audio/wav",
httpErrorLabel: "Audio transcription failed",
missingTextError: "Audio transcription response missing text",
});
return { text, model };
}
export async function describeGeminiVideo(
params: VideoDescriptionRequest,
): Promise<VideoDescriptionResult> {
const { text, model } = await generateGeminiInlineDataText({
...params,
defaultBaseUrl: DEFAULT_GOOGLE_VIDEO_BASE_URL,
defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL,
defaultPrompt: DEFAULT_GOOGLE_VIDEO_PROMPT,
defaultMime: "video/mp4",
httpErrorLabel: "Video description failed",
missingTextError: "Video description response missing text",
});
return { text, model };
}
export const googleMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "google",
capabilities: ["image", "audio", "video"],
describeImage: describeImageWithModel,
transcribeAudio: transcribeGeminiAudio,
describeVideo: describeGeminiVideo,
};

View File

@ -1,22 +1,10 @@
import {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
migrateBaseNameToDefaultAccount,
} from "../../../src/channels/plugins/setup-helpers.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
const channel = "googlechat" as const;
export const googlechatSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
export const googlechatSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: channel,
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.";
@ -26,20 +14,7 @@ export const googlechatSetupAdapter: ChannelSetupAdapter = {
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
buildPatch: (input) => {
const patch = input.useEnv
? {}
: input.tokenFile
@ -51,17 +26,12 @@ export const googlechatSetupAdapter: ChannelSetupAdapter = {
const audience = input.audience?.trim();
const webhookPath = input.webhookPath?.trim();
const webhookUrl = input.webhookUrl?.trim();
return applySetupAccountConfigPatch({
cfg: next,
channelKey: channel,
accountId,
patch: {
...patch,
...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}),
...(webhookUrl ? { webhookUrl } : {}),
},
});
return {
...patch,
...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}),
...(webhookUrl ? { webhookUrl } : {}),
};
},
};
});

View File

@ -1,19 +1,17 @@
import {
applySetupAccountConfigPatch,
migrateBaseNameToDefaultAccount,
} from "../../../src/channels/plugins/setup-helpers.js";
import {
addWildcardAllowFrom,
applySetupAccountConfigPatch,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
migrateBaseNameToDefaultAccount,
setTopLevelChannelDmPolicyWithAllowFrom,
splitSetupEntries,
} from "../../../src/channels/plugins/setup-wizard-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import {
listGoogleChatAccountIds,
resolveDefaultGoogleChatAccountId,

View File

@ -1,9 +1,6 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import {
applyHuggingfaceConfig,
HUGGINGFACE_DEFAULT_MODEL_REF,
} from "../../src/commands/onboard-auth.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildHuggingfaceProvider } from "./provider-catalog.js";
const PROVIDER_ID = "huggingface";

View File

@ -0,0 +1,35 @@
import {
buildHuggingfaceModelDefinition,
HUGGINGFACE_BASE_URL,
HUGGINGFACE_MODEL_CATALOG,
} from "../../src/agents/huggingface-models.js";
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithModelCatalog,
} from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1";
export function applyHuggingfaceProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[HUGGINGFACE_DEFAULT_MODEL_REF] = {
...models[HUGGINGFACE_DEFAULT_MODEL_REF],
alias: models[HUGGINGFACE_DEFAULT_MODEL_REF]?.alias ?? "Hugging Face",
};
return applyProviderConfigWithModelCatalog(cfg, {
agentModels: models,
providerId: "huggingface",
api: "openai-completions",
baseUrl: HUGGINGFACE_BASE_URL,
catalogModels: HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition),
});
}
export function applyHuggingfaceConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyHuggingfaceProviderConfig(cfg),
HUGGINGFACE_DEFAULT_MODEL_REF,
);
}

View File

@ -1,10 +1,10 @@
import type { IMessageAccountConfig } from "openclaw/plugin-sdk/imessage";
import {
type OpenClawConfig,
createAccountListHelpers,
normalizeAccountId,
resolveAccountEntry,
} from "../../../src/plugin-sdk-internal/accounts.js";
import type { IMessageAccountConfig } from "../../../src/plugin-sdk-internal/imessage.js";
export type ResolvedIMessageAccount = {
accountId: string;

View File

@ -1,101 +1,11 @@
import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatTrimmedAllowFromEntries,
getChatChannelMeta,
IMessageConfigSchema,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk/imessage";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
type ResolvedIMessageAccount,
} from "./accounts.js";
import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js";
import { type ChannelPlugin } from "openclaw/plugin-sdk/imessage";
import { type ResolvedIMessageAccount } from "./accounts.js";
import { imessageSetupAdapter } from "./setup-core.js";
import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js";
async function loadIMessageChannelRuntime() {
return await import("./channel.runtime.js");
}
const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({
imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard,
}));
export const imessageSetupPlugin: ChannelPlugin<ResolvedIMessageAccount> = {
id: "imessage",
meta: {
...getChatChannelMeta("imessage"),
aliases: ["imsg"],
showConfigured: false,
export const imessageSetupPlugin: ChannelPlugin<ResolvedIMessageAccount> = createIMessagePluginBase(
{
setupWizard: imessageSetupWizard,
setup: imessageSetupAdapter,
},
setupWizard: imessageSetupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
},
reload: { configPrefixes: ["channels.imessage"] },
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
config: {
listAccountIds: (cfg) => listIMessageAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "imessage",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "imessage",
accountId,
clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }),
formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) =>
buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "imessage",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
}),
collectWarnings: ({ account, cfg }) =>
collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.imessage !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
surface: "iMessage groups",
openScope: "any member",
groupPolicyPath: "channels.imessage.groupPolicy",
groupAllowFromPath: "channels.imessage.groupAllowFrom",
mentionGated: false,
}),
},
setup: imessageSetupAdapter,
};
);

View File

@ -1,50 +1,25 @@
import {
buildAccountScopedAllowlistConfigEditor,
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/compat";
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat";
import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core";
import {
buildChannelConfigSchema,
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatTrimmedAllowFromEntries,
getChatChannelMeta,
IMessageConfigSchema,
looksLikeIMessageTargetId,
normalizeIMessageMessagingTarget,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk/imessage";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
type ResolvedIMessageAccount,
} from "./accounts.js";
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
import { getIMessageRuntime } from "./runtime.js";
import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js";
import { imessageSetupAdapter } from "./setup-core.js";
import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js";
import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
const meta = getChatChannelMeta("imessage");
async function loadIMessageChannelRuntime() {
return await import("./channel.runtime.js");
}
const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({
imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard,
}));
type IMessageSendFn = ReturnType<
typeof getIMessageRuntime
>["channel"]["imessage"]["sendMessageIMessage"];
@ -157,55 +132,16 @@ function resolveIMessageOutboundSessionRoute(params: {
}
export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
id: "imessage",
meta: {
...meta,
aliases: ["imsg"],
showConfigured: false,
},
setupWizard: imessageSetupWizard,
...createIMessagePluginBase({
setupWizard: imessageSetupWizard,
setup: imessageSetupAdapter,
}),
pairing: {
idLabel: "imessageSenderId",
notifyApproval: async ({ id }) => {
await getIMessageRuntime().channel.imessage.sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
},
reload: { configPrefixes: ["channels.imessage"] },
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
config: {
listAccountIds: (cfg) => listIMessageAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "imessage",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "imessage",
accountId,
clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }),
formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }),
},
allowlist: {
supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all",
readConfig: ({ cfg, accountId }) => {
@ -226,31 +162,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
}),
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "imessage",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
});
},
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.imessage !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
surface: "iMessage groups",
openScope: "any member",
groupPolicyPath: "channels.imessage.groupPolicy",
groupAllowFromPath: "channels.imessage.groupAllowFrom",
mentionGated: false,
});
},
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,
resolveToolPolicy: resolveIMessageGroupToolPolicy,
@ -263,7 +174,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
hint: "<handle|chat_id:ID>",
},
},
setup: imessageSetupAdapter,
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),

View File

@ -0,0 +1,11 @@
import { type ChannelPlugin } from "../../../src/plugin-sdk-internal/imessage.js";
import { type ResolvedIMessageAccount } from "./accounts.js";
import { createIMessageSetupWizardProxy } from "./setup-core.js";
async function loadIMessageChannelRuntime() {
return await import("./channel.runtime.js");
}
export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({
imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard,
})) satisfies NonNullable<ChannelPlugin<ResolvedIMessageAccount>["setupWizard"]>;

View File

@ -1,5 +1,7 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import {
createPluginRuntimeStore,
type PluginRuntime,
} from "../../../src/plugin-sdk-internal/core.js";
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");

View File

@ -1,9 +1,5 @@
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
import {
applyAccountNameToChannelSection,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForScopedChannel,
setChannelDmPolicyWithAllowFrom,
@ -16,6 +12,7 @@ import type {
ChannelSetupDmPolicy,
ChannelSetupWizard,
} from "../../../src/plugin-sdk-internal/setup.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
@ -98,66 +95,23 @@ async function promptIMessageAllowFrom(params: {
});
}
export const imessageSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
...buildIMessageSetupPatch(input),
},
},
};
}
return {
...next,
channels: {
...next.channels,
imessage: {
...next.channels?.imessage,
enabled: true,
accounts: {
...next.channels?.imessage?.accounts,
[accountId]: {
...next.channels?.imessage?.accounts?.[accountId],
enabled: true,
...buildIMessageSetupPatch(input),
},
},
},
},
};
},
export const imessageSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: channel,
buildPatch: (input) => buildIMessageSetupPatch(input),
});
type IMessageSetupWizardHandlers = {
resolveStatusLines: NonNullable<ChannelSetupWizard["status"]>["resolveStatusLines"];
resolveSelectionHint: NonNullable<ChannelSetupWizard["status"]>["resolveSelectionHint"];
resolveQuickstartScore: NonNullable<ChannelSetupWizard["status"]>["resolveQuickstartScore"];
shouldPromptCliPath: NonNullable<
NonNullable<ChannelSetupWizard["textInputs"]>[number]["shouldPrompt"]
>;
};
export function createIMessageSetupWizardProxy(
loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>,
) {
export function createIMessageSetupWizardBase(
handlers: IMessageSetupWizardHandlers,
): ChannelSetupWizard {
const imessageDmPolicy: ChannelSetupDmPolicy = {
label: "iMessage",
channel,
@ -193,12 +147,9 @@ export function createIMessageSetupWizardProxy(
account.config.region,
);
}),
resolveStatusLines: async (params) =>
(await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [],
resolveSelectionHint: async (params) =>
await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params),
resolveQuickstartScore: async (params) =>
await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params),
resolveStatusLines: handlers.resolveStatusLines,
resolveSelectionHint: handlers.resolveSelectionHint,
resolveQuickstartScore: handlers.resolveQuickstartScore,
},
credentials: [],
textInputs: [
@ -209,12 +160,7 @@ export function createIMessageSetupWizardProxy(
resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg",
currentValue: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg",
shouldPrompt: async (params) => {
const input = (await loadWizard()).imessageSetupWizard.textInputs?.find(
(entry) => entry.inputKey === "cliPath",
);
return (await input?.shouldPrompt?.(params)) ?? false;
},
shouldPrompt: handlers.shouldPromptCliPath,
confirmCurrentValue: false,
applyCurrentValue: true,
helpTitle: "iMessage",
@ -235,3 +181,22 @@ export function createIMessageSetupWizardProxy(
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;
}
export function createIMessageSetupWizardProxy(
loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>,
) {
return createIMessageSetupWizardBase({
resolveStatusLines: async (params) =>
(await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [],
resolveSelectionHint: async (params) =>
await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params),
resolveQuickstartScore: async (params) =>
await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params),
shouldPromptCliPath: async (params) => {
const input = (await loadWizard()).imessageSetupWizard.textInputs?.find(
(entry) => entry.inputKey === "cliPath",
);
return (await input?.shouldPrompt?.(params)) ?? false;
},
});
}

View File

@ -1,137 +1,23 @@
import {
DEFAULT_ACCOUNT_ID,
detectBinary,
formatDocsLink,
type OpenClawConfig,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForScopedChannel,
setChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type WizardPrompter,
} from "../../../src/plugin-sdk-internal/setup.js";
import type {
ChannelSetupDmPolicy,
ChannelSetupWizard,
} from "../../../src/plugin-sdk-internal/setup.js";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
} from "./accounts.js";
import { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js";
import { detectBinary } from "../../../src/plugin-sdk-internal/setup.js";
import { createIMessageSetupWizardBase, imessageSetupAdapter } from "./setup-core.js";
const channel = "imessage" as const;
async function promptIMessageAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
return promptParsedAllowFromForScopedChannel({
cfg: params.cfg,
channel,
accountId: params.accountId,
defaultAccountId: resolveDefaultIMessageAccountId(params.cfg),
prompter: params.prompter,
noteTitle: "iMessage allowlist",
noteLines: [
"Allowlist iMessage DMs by handle or chat target.",
"Examples:",
"- +15555550123",
"- user@example.com",
"- chat_id:123",
"- chat_guid:... or chat_identifier:...",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/imessage", "imessage")}`,
],
message: "iMessage allowFrom (handle or chat_id)",
placeholder: "+15555550123, user@example.com, chat_id:123",
parseEntries: parseIMessageAllowFromEntries,
getExistingAllowFrom: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [],
});
}
const imessageDmPolicy: ChannelSetupDmPolicy = {
label: "iMessage",
channel,
policyKey: "channels.imessage.dmPolicy",
allowFromKey: "channels.imessage.allowFrom",
getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: promptIMessageAllowFrom,
};
export const imessageSetupWizard: ChannelSetupWizard = {
channel,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "imsg found",
unconfiguredHint: "imsg missing",
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: ({ cfg }) =>
listIMessageAccountIds(cfg).some((accountId) => {
const account = resolveIMessageAccount({ cfg, accountId });
return Boolean(
account.config.cliPath ||
account.config.dbPath ||
account.config.allowFrom ||
account.config.service ||
account.config.region,
);
}),
resolveStatusLines: async ({ cfg, configured }) => {
const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg";
const cliDetected = await detectBinary(cliPath);
return [
`iMessage: ${configured ? "configured" : "needs setup"}`,
`imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`,
];
},
resolveSelectionHint: async ({ cfg }) => {
const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg";
return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing";
},
resolveQuickstartScore: async ({ cfg }) => {
const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg";
return (await detectBinary(cliPath)) ? 1 : 0;
},
export const imessageSetupWizard = createIMessageSetupWizardBase({
resolveStatusLines: async ({ cfg, configured }) => {
const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg";
const cliDetected = await detectBinary(cliPath);
return [
`iMessage: ${configured ? "configured" : "needs setup"}`,
`imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`,
];
},
credentials: [],
textInputs: [
{
inputKey: "cliPath",
message: "imsg CLI path",
initialValue: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg",
currentValue: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg",
shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")),
confirmCurrentValue: false,
applyCurrentValue: true,
helpTitle: "iMessage",
helpLines: ["imsg CLI path required to enable iMessage."],
},
],
completionNote: {
title: "iMessage next steps",
lines: [
"This is still a work in progress.",
"Ensure OpenClaw has Full Disk Access to Messages DB.",
"Grant Automation permission for Messages when prompted.",
"List chats with: imsg chats --limit 20",
`Docs: ${formatDocsLink("/imessage", "imessage")}`,
],
resolveSelectionHint: async ({ cfg }) => {
const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg";
return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing";
},
dmPolicy: imessageDmPolicy,
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
export { imessageSetupAdapter, parseIMessageAllowFromEntries };
resolveQuickstartScore: async ({ cfg }) => {
const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg";
return (await detectBinary(cliPath)) ? 1 : 0;
},
shouldPromptCliPath: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")),
});
export { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js";

View File

@ -0,0 +1,119 @@
import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatTrimmedAllowFromEntries,
getChatChannelMeta,
IMessageConfigSchema,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk/imessage";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
type ResolvedIMessageAccount,
} from "./accounts.js";
import { createIMessageSetupWizardProxy } from "./setup-core.js";
export const IMESSAGE_CHANNEL = "imessage" as const;
async function loadIMessageChannelRuntime() {
return await import("./channel.runtime.js");
}
export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({
imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard,
}));
export function createIMessagePluginBase(params: {
setupWizard?: NonNullable<ChannelPlugin<ResolvedIMessageAccount>["setupWizard"]>;
setup: NonNullable<ChannelPlugin<ResolvedIMessageAccount>["setup"]>;
}): Pick<
ChannelPlugin<ResolvedIMessageAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "reload"
| "configSchema"
| "config"
| "security"
| "setup"
> {
return {
id: IMESSAGE_CHANNEL,
meta: {
...getChatChannelMeta(IMESSAGE_CHANNEL),
aliases: ["imsg"],
showConfigured: false,
},
setupWizard: params.setupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
},
reload: { configPrefixes: ["channels.imessage"] },
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
config: {
listAccountIds: (cfg) => listIMessageAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: IMESSAGE_CHANNEL,
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: IMESSAGE_CHANNEL,
accountId,
clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
}),
resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }),
formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) =>
buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: IMESSAGE_CHANNEL,
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
}),
collectWarnings: ({ account, cfg }) =>
collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.imessage !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
surface: "iMessage groups",
openScope: "any member",
groupPolicyPath: "channels.imessage.groupPolicy",
groupAllowFromPath: "channels.imessage.groupAllowFrom",
mentionGated: false,
}),
},
setup: params.setup,
};
}

View File

@ -1,4 +1,4 @@
import { isAllowedParsedChatSender } from "../../../src/plugin-sdk/allow-from.js";
import { isAllowedParsedChatSender } from "../../../src/plugin-sdk-internal/imessage.js";
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };

View File

@ -1,6 +1,6 @@
import {
applyAccountNameToChannelSection,
patchScopedAccountConfig,
prepareScopedSetupConfig,
} from "../../../src/channels/plugins/setup-helpers.js";
import {
setTopLevelChannelAllowFrom,
@ -100,7 +100,7 @@ export function setIrcGroupAccess(
export const ircSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
prepareScopedSetupConfig({
cfg,
channelKey: channel,
accountId,
@ -118,7 +118,7 @@ export const ircSetupAdapter: ChannelSetupAdapter = {
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const setupInput = input as IrcSetupInput;
const namedConfig = applyAccountNameToChannelSection({
const namedConfig = prepareScopedSetupConfig({
cfg,
channelKey: channel,
accountId,

View File

@ -3,11 +3,9 @@ import {
createKilocodeWrapper,
isProxyReasoningUnsupported,
} from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js";
import {
applyKilocodeConfig,
KILOCODE_DEFAULT_MODEL_REF,
} from "../../src/commands/onboard-auth.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js";
import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js";
const PROVIDER_ID = "kilocode";
@ -47,18 +45,12 @@ const kilocodePlugin = {
],
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
return {
provider: {
...(await buildKilocodeProviderWithDiscovery()),
apiKey,
},
};
},
run: (ctx) =>
buildSingleProviderApiKeyCatalog({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildKilocodeProviderWithDiscovery,
}),
},
capabilities: {
geminiThoughtSignatureSanitization: true,

View File

@ -0,0 +1,35 @@
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithModelCatalog,
} from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
import {
KILOCODE_BASE_URL,
KILOCODE_DEFAULT_MODEL_REF,
} from "../../src/providers/kilocode-shared.js";
import { buildKilocodeProvider } from "./provider-catalog.js";
export { KILOCODE_BASE_URL, KILOCODE_DEFAULT_MODEL_REF };
export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[KILOCODE_DEFAULT_MODEL_REF] = {
...models[KILOCODE_DEFAULT_MODEL_REF],
alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway",
};
return applyProviderConfigWithModelCatalog(cfg, {
agentModels: models,
providerId: "kilocode",
api: "openai-completions",
baseUrl: KILOCODE_BASE_URL,
catalogModels: buildKilocodeProvider().models ?? [],
});
}
export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyKilocodeProviderConfig(cfg),
KILOCODE_DEFAULT_MODEL_REF,
);
}

View File

@ -1,47 +1,69 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "../../src/commands/onboard-auth.js";
import {
emptyPluginConfigSchema,
type OpenClawPluginApi,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { findNormalizedProviderValue, normalizeProviderId } from "../../src/agents/provider-id.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { isRecord } from "../../src/utils.js";
import { buildKimiCodingProvider } from "./provider-catalog.js";
import { applyKimiCodeConfig, KIMI_DEFAULT_MODEL_REF } from "./onboard.js";
import {
buildKimiProvider,
KIMI_DEFAULT_MODEL_ID,
KIMI_LEGACY_MODEL_ID,
KIMI_UPSTREAM_MODEL_ID,
} from "./provider-catalog.js";
const PROVIDER_ID = "kimi-coding";
const PROVIDER_ID = "kimi";
const KIMI_TRANSPORT_MODEL_IDS = new Set([KIMI_DEFAULT_MODEL_ID, KIMI_LEGACY_MODEL_ID]);
function normalizeKimiTransportModel(model: ProviderRuntimeModel): ProviderRuntimeModel {
if (!KIMI_TRANSPORT_MODEL_IDS.has(model.id)) {
return model;
}
return {
...model,
id: KIMI_UPSTREAM_MODEL_ID,
name: "Kimi Code",
};
}
const kimiCodingPlugin = {
id: PROVIDER_ID,
name: "Kimi Coding Provider",
description: "Bundled Kimi Coding provider plugin",
name: "Kimi Code Provider",
description: "Bundled Kimi Code provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: PROVIDER_ID,
label: "Kimi Coding",
aliases: ["kimi-code"],
label: "Kimi Code",
aliases: ["kimi-code", "kimi-coding"],
docsPath: "/providers/moonshot",
envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "Kimi Code API key (subscription)",
hint: "Kimi K2.5 + Kimi Coding",
label: "Kimi Code API key",
hint: "Dedicated coding endpoint",
optionKey: "kimiCodeApiKey",
flagName: "--kimi-code-api-key",
envVar: "KIMI_API_KEY",
promptMessage: "Enter Kimi Coding API key",
defaultModel: KIMI_CODING_MODEL_REF,
expectedProviders: ["kimi-code", "kimi-coding"],
promptMessage: "Enter Kimi Code API key",
defaultModel: KIMI_DEFAULT_MODEL_REF,
expectedProviders: ["kimi", "kimi-code", "kimi-coding"],
applyConfig: (cfg) => applyKimiCodeConfig(cfg),
noteMessage: [
"Kimi Coding uses a dedicated endpoint and API key.",
"Kimi Code uses a dedicated coding endpoint and API key.",
"Get your API key at: https://www.kimi.com/code/en",
].join("\n"),
noteTitle: "Kimi Coding",
noteTitle: "Kimi Code",
wizard: {
choiceId: "kimi-code-api-key",
choiceLabel: "Kimi Code API key (subscription)",
groupId: "moonshot",
groupLabel: "Moonshot AI (Kimi K2.5)",
groupHint: "Kimi K2.5 + Kimi Coding",
choiceLabel: "Kimi Code API key",
groupId: "kimi-code",
groupLabel: "Kimi Code",
groupHint: "Dedicated coding endpoint",
},
}),
],
@ -52,8 +74,11 @@ const kimiCodingPlugin = {
if (!apiKey) {
return null;
}
const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID];
const builtInProvider = buildKimiCodingProvider();
const explicitProvider = findNormalizedProviderValue(
ctx.config.models?.providers,
PROVIDER_ID,
);
const builtInProvider = buildKimiProvider();
const explicitBaseUrl =
typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : "";
const explicitHeaders = isRecord(explicitProvider?.headers)
@ -79,6 +104,12 @@ const kimiCodingPlugin = {
capabilities: {
preserveAnthropicThinkingSignatures: false,
},
normalizeResolvedModel: (ctx) => {
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {
return undefined;
}
return normalizeKimiTransportModel(ctx.model);
},
});
},
};

View File

@ -0,0 +1,44 @@
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithModelCatalog,
} from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
import {
buildKimiCodingProvider,
KIMI_BASE_URL,
KIMI_DEFAULT_MODEL_ID,
KIMI_LEGACY_MODEL_ID,
} from "./provider-catalog.js";
export const KIMI_DEFAULT_MODEL_REF = `kimi/${KIMI_DEFAULT_MODEL_ID}`;
export const KIMI_LEGACY_MODEL_REF = `kimi/${KIMI_LEGACY_MODEL_ID}`;
export const KIMI_CODING_MODEL_REF = KIMI_DEFAULT_MODEL_REF;
export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[KIMI_DEFAULT_MODEL_REF] = {
...models[KIMI_DEFAULT_MODEL_REF],
alias: models[KIMI_DEFAULT_MODEL_REF]?.alias ?? "Kimi Code",
};
models[KIMI_LEGACY_MODEL_REF] = {
...models[KIMI_LEGACY_MODEL_REF],
alias: models[KIMI_LEGACY_MODEL_REF]?.alias ?? "Kimi Code",
};
const catalog = buildKimiCodingProvider().models ?? [];
if (catalog.length === 0) {
return cfg;
}
return applyProviderConfigWithModelCatalog(cfg, {
agentModels: models,
providerId: "kimi",
api: "anthropic-messages",
baseUrl: KIMI_BASE_URL,
catalogModels: catalog,
});
}
export function applyKimiCodeConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(applyKimiCodeProviderConfig(cfg), KIMI_DEFAULT_MODEL_REF);
}

View File

@ -1,22 +1,23 @@
{
"id": "kimi-coding",
"providers": ["kimi-coding"],
"id": "kimi",
"providers": ["kimi", "kimi-coding"],
"providerAuthEnvVars": {
"kimi": ["KIMI_API_KEY", "KIMICODE_API_KEY"],
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "kimi-coding",
"provider": "kimi",
"method": "api-key",
"choiceId": "kimi-code-api-key",
"choiceLabel": "Kimi Code API key (subscription)",
"groupId": "moonshot",
"groupLabel": "Moonshot AI (Kimi K2.5)",
"groupHint": "Kimi K2.5 + Kimi Coding",
"choiceLabel": "Kimi Code API key",
"groupId": "kimi-code",
"groupLabel": "Kimi Code",
"groupHint": "Dedicated coding endpoint",
"optionKey": "kimiCodeApiKey",
"cliFlag": "--kimi-code-api-key",
"cliOption": "--kimi-code-api-key <key>",
"cliDescription": "Kimi Coding API key"
"cliDescription": "Kimi Code API key"
}
],
"configSchema": {

View File

@ -1,8 +1,8 @@
{
"name": "@openclaw/kimi-coding-provider",
"name": "@openclaw/kimi-provider",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Kimi Coding provider plugin",
"description": "OpenClaw Kimi provider plugin",
"type": "module",
"openclaw": {
"extensions": [

View File

@ -1,8 +1,10 @@
import type { ModelProviderConfig } from "../../src/config/types.models.js";
export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/";
export const KIMI_BASE_URL = "https://api.kimi.com/coding/";
const KIMI_CODING_USER_AGENT = "claude-code/0.1.0";
export const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5";
export const KIMI_DEFAULT_MODEL_ID = "kimi-code";
export const KIMI_UPSTREAM_MODEL_ID = "kimi-for-coding";
export const KIMI_LEGACY_MODEL_ID = "k2p5";
const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144;
const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768;
const KIMI_CODING_DEFAULT_COST = {
@ -14,15 +16,24 @@ const KIMI_CODING_DEFAULT_COST = {
export function buildKimiCodingProvider(): ModelProviderConfig {
return {
baseUrl: KIMI_CODING_BASE_URL,
baseUrl: KIMI_BASE_URL,
api: "anthropic-messages",
headers: {
"User-Agent": KIMI_CODING_USER_AGENT,
},
models: [
{
id: KIMI_CODING_DEFAULT_MODEL_ID,
name: "Kimi for Coding",
id: KIMI_DEFAULT_MODEL_ID,
name: "Kimi Code",
reasoning: true,
input: ["text", "image"],
cost: KIMI_CODING_DEFAULT_COST,
contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW,
maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS,
},
{
id: KIMI_LEGACY_MODEL_ID,
name: "Kimi Code (legacy model id)",
reasoning: true,
input: ["text", "image"],
cost: KIMI_CODING_DEFAULT_COST,
@ -32,3 +43,8 @@ export function buildKimiCodingProvider(): ModelProviderConfig {
],
};
}
export const KIMI_CODING_BASE_URL = KIMI_BASE_URL;
export const KIMI_CODING_DEFAULT_MODEL_ID = KIMI_DEFAULT_MODEL_ID;
export const KIMI_CODING_LEGACY_MODEL_ID = KIMI_LEGACY_MODEL_ID;
export const buildKimiProvider = buildKimiCodingProvider;

View File

@ -45,6 +45,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
registerService() {},
registerProvider() {},
registerSpeechProvider() {},
registerMediaUnderstandingProvider() {},
registerWebSearchProvider() {},
registerInteractiveHandler() {},
registerHook() {},

View File

@ -1,10 +1,7 @@
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "../../../src/channels/plugins/setup-helpers.js";
import { prepareScopedSetupConfig } from "../../../src/channels/plugins/setup-helpers.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import { normalizeSecretInputString } from "../../../src/config/types.secrets.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { normalizeAccountId } from "../../../src/routing/session-key.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
@ -44,12 +41,12 @@ export function buildMatrixConfigUpdate(
export const matrixSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
prepareScopedSetupConfig({
cfg: cfg as CoreConfig,
channelKey: channel,
accountId,
name,
}),
}) as CoreConfig,
validateInput: ({ input }) => {
if (input.useEnv) {
return null;
@ -74,19 +71,13 @@ export const matrixSetupAdapter: ChannelSetupAdapter = {
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
const next = prepareScopedSetupConfig({
cfg: cfg as CoreConfig,
channelKey: channel,
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
migrateBaseName: true,
}) as CoreConfig;
if (input.useEnv) {
return {
...next,

View File

@ -1,20 +1,20 @@
import {
addWildcardAllowFrom,
buildSingleChannelSecretPromptState,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
formatResolvedUnresolvedNote,
hasConfiguredSecretInput,
mergeAllowFromEntries,
promptSingleChannelSecretInput,
setTopLevelChannelGroupPolicy,
} from "../../../src/channels/plugins/setup-wizard-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import type { SecretInput } from "../../../src/config/types.secrets.js";
import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js";
import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
type SecretInput,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";

View File

@ -1,12 +1,9 @@
import {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
@ -27,15 +24,8 @@ export function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, account
});
}
export const mattermostSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
export const mattermostSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: channel,
validateInput: ({ accountId, input }) => {
const token = input.botToken ?? input.token;
const baseUrl = normalizeMattermostBaseUrl(input.httpUrl);
@ -50,32 +40,14 @@ export const mattermostSetupAdapter: ChannelSetupAdapter = {
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
buildPatch: (input) => {
const token = input.botToken ?? input.token;
const baseUrl = normalizeMattermostBaseUrl(input.httpUrl);
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
return applySetupAccountConfigPatch({
cfg: next,
channelKey: channel,
accountId,
patch: input.useEnv
? {}
: {
...(token ? { botToken: token } : {}),
...(baseUrl ? { baseUrl } : {}),
},
});
return input.useEnv
? {}
: {
...(token ? { botToken: token } : {}),
...(baseUrl ? { baseUrl } : {}),
};
},
};
});

View File

@ -8,13 +8,14 @@ import {
} from "openclaw/plugin-sdk/minimax-portal-auth";
import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js";
import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js";
import {
applyMinimaxApiConfig,
applyMinimaxApiConfigCn,
} from "../../src/commands/onboard-auth.config-minimax.js";
import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import {
minimaxMediaUnderstandingProvider,
minimaxPortalMediaUnderstandingProvider,
} from "./media-understanding-provider.js";
import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js";
import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js";
import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js";
const API_PROVIDER_ID = "minimax";
@ -273,6 +274,8 @@ const minimaxPlugin = {
],
isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId),
});
api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider);
api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider);
},
};

View File

@ -0,0 +1,14 @@
import { describeImageWithModel } from "../../src/media-understanding/providers/image.js";
import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js";
export const minimaxMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "minimax",
capabilities: ["image"],
describeImage: describeImageWithModel,
};
export const minimaxPortalMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "minimax-portal",
capabilities: ["image"],
describeImage: describeImageWithModel,
};

View File

@ -0,0 +1,64 @@
import type { ModelDefinitionConfig } from "../../src/config/types.models.js";
export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic";
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.5";
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;
export const DEFAULT_MINIMAX_MAX_TOKENS = 8192;
export const MINIMAX_API_COST = {
input: 0.3,
output: 1.2,
cacheRead: 0.03,
cacheWrite: 0.12,
};
export const MINIMAX_HOSTED_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export const MINIMAX_LM_STUDIO_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const MINIMAX_MODEL_CATALOG = {
"MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true },
"MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true },
} as const;
type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG;
export function buildMinimaxModelDefinition(params: {
id: string;
name?: string;
reasoning?: boolean;
cost: ModelDefinitionConfig["cost"];
contextWindow: number;
maxTokens: number;
}): ModelDefinitionConfig {
const catalog = MINIMAX_MODEL_CATALOG[params.id as MinimaxCatalogId];
return {
id: params.id,
name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`,
reasoning: params.reasoning ?? catalog?.reasoning ?? false,
input: ["text"],
cost: params.cost,
contextWindow: params.contextWindow,
maxTokens: params.maxTokens,
};
}
export function buildMinimaxApiModelDefinition(modelId: string): ModelDefinitionConfig {
return buildMinimaxModelDefinition({
id: modelId,
cost: MINIMAX_API_COST,
contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW,
maxTokens: DEFAULT_MINIMAX_MAX_TOKENS,
});
}

View File

@ -0,0 +1,104 @@
import {
applyAgentDefaultModelPrimary,
applyOnboardAuthAgentModelsAndProviders,
} from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
import type { ModelProviderConfig } from "../../src/config/types.models.js";
import {
buildMinimaxApiModelDefinition,
MINIMAX_API_BASE_URL,
MINIMAX_CN_API_BASE_URL,
} from "./model-definitions.js";
type MinimaxApiProviderConfigParams = {
providerId: string;
modelId: string;
baseUrl: string;
};
function applyMinimaxApiProviderConfigWithBaseUrl(
cfg: OpenClawConfig,
params: MinimaxApiProviderConfigParams,
): OpenClawConfig {
const providers = { ...cfg.models?.providers } as Record<string, ModelProviderConfig>;
const existingProvider = providers[params.providerId];
const existingModels = existingProvider?.models ?? [];
const apiModel = buildMinimaxApiModelDefinition(params.modelId);
const hasApiModel = existingModels.some((model) => model.id === params.modelId);
const mergedModels = hasApiModel ? existingModels : [...existingModels, apiModel];
const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {
baseUrl: params.baseUrl,
models: [],
};
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
const normalizedApiKey = resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey;
providers[params.providerId] = {
...existingProviderRest,
baseUrl: params.baseUrl,
api: "anthropic-messages",
authHeader: true,
...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}),
models: mergedModels.length > 0 ? mergedModels : [apiModel],
};
const models = { ...cfg.agents?.defaults?.models };
const modelRef = `${params.providerId}/${params.modelId}`;
models[modelRef] = {
...models[modelRef],
alias: "Minimax",
};
return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers });
}
function applyMinimaxApiConfigWithBaseUrl(
cfg: OpenClawConfig,
params: MinimaxApiProviderConfigParams,
): OpenClawConfig {
const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, params);
return applyAgentDefaultModelPrimary(next, `${params.providerId}/${params.modelId}`);
}
export function applyMinimaxApiProviderConfig(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
): OpenClawConfig {
return applyMinimaxApiProviderConfigWithBaseUrl(cfg, {
providerId: "minimax",
modelId,
baseUrl: MINIMAX_API_BASE_URL,
});
}
export function applyMinimaxApiConfig(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
): OpenClawConfig {
return applyMinimaxApiConfigWithBaseUrl(cfg, {
providerId: "minimax",
modelId,
baseUrl: MINIMAX_API_BASE_URL,
});
}
export function applyMinimaxApiProviderConfigCn(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
): OpenClawConfig {
return applyMinimaxApiProviderConfigWithBaseUrl(cfg, {
providerId: "minimax",
modelId,
baseUrl: MINIMAX_CN_API_BASE_URL,
});
}
export function applyMinimaxApiConfigCn(
cfg: OpenClawConfig,
modelId: string = "MiniMax-M2.5",
): OpenClawConfig {
return applyMinimaxApiConfigWithBaseUrl(cfg, {
providerId: "minimax",
modelId,
baseUrl: MINIMAX_CN_API_BASE_URL,
});
}

View File

@ -1,6 +1,7 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js";
const PROVIDER_ID = "mistral";
@ -50,6 +51,7 @@ const mistralPlugin = {
],
},
});
api.registerMediaUnderstandingProvider(mistralMediaUnderstandingProvider);
},
};

View File

@ -0,0 +1,17 @@
import { transcribeOpenAiCompatibleAudio } from "../../src/media-understanding/providers/openai-compatible-audio.js";
import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js";
const DEFAULT_MISTRAL_AUDIO_BASE_URL = "https://api.mistral.ai/v1";
const DEFAULT_MISTRAL_AUDIO_MODEL = "voxtral-mini-latest";
export const mistralMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "mistral",
capabilities: ["audio"],
transcribeAudio: async (req) =>
await transcribeOpenAiCompatibleAudio({
...req,
baseUrl: req.baseUrl ?? DEFAULT_MISTRAL_AUDIO_BASE_URL,
defaultBaseUrl: DEFAULT_MISTRAL_AUDIO_BASE_URL,
defaultModel: DEFAULT_MISTRAL_AUDIO_MODEL,
}),
};

View File

@ -0,0 +1,25 @@
import type { ModelDefinitionConfig } from "../../src/config/types.models.js";
export const MISTRAL_BASE_URL = "https://api.mistral.ai/v1";
export const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest";
export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`;
export const MISTRAL_DEFAULT_CONTEXT_WINDOW = 262144;
export const MISTRAL_DEFAULT_MAX_TOKENS = 262144;
export const MISTRAL_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
export function buildMistralModelDefinition(): ModelDefinitionConfig {
return {
id: MISTRAL_DEFAULT_MODEL_ID,
name: "Mistral Large",
reasoning: false,
input: ["text", "image"],
cost: MISTRAL_DEFAULT_COST,
contextWindow: MISTRAL_DEFAULT_CONTEXT_WINDOW,
maxTokens: MISTRAL_DEFAULT_MAX_TOKENS,
};
}

View File

@ -0,0 +1,34 @@
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithDefaultModel,
} from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
import {
buildMistralModelDefinition,
MISTRAL_BASE_URL,
MISTRAL_DEFAULT_MODEL_ID,
MISTRAL_DEFAULT_MODEL_REF,
} from "./model-definitions.js";
export { MISTRAL_DEFAULT_MODEL_REF };
export function applyMistralProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[MISTRAL_DEFAULT_MODEL_REF] = {
...models[MISTRAL_DEFAULT_MODEL_REF],
alias: models[MISTRAL_DEFAULT_MODEL_REF]?.alias ?? "Mistral",
};
return applyProviderConfigWithDefaultModel(cfg, {
agentModels: models,
providerId: "mistral",
api: "openai-completions",
baseUrl: MISTRAL_BASE_URL,
defaultModel: buildMistralModelDefinition(),
defaultModelId: MISTRAL_DEFAULT_MODEL_ID,
});
}
export function applyMistralConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(applyMistralProviderConfig(cfg), MISTRAL_DEFAULT_MODEL_REF);
}

View File

@ -1,10 +1,11 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js";
import {
applyModelStudioConfig,
applyModelStudioConfigCn,
MODELSTUDIO_DEFAULT_MODEL_REF,
} from "../../src/commands/onboard-auth.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
} from "./onboard.js";
import { buildModelStudioProvider } from "./provider-catalog.js";
const PROVIDER_ID = "modelstudio";
@ -78,22 +79,13 @@ const modelStudioPlugin = {
],
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID];
const explicitBaseUrl =
typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : "";
return {
provider: {
...buildModelStudioProvider(),
...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}),
apiKey,
},
};
},
run: (ctx) =>
buildSingleProviderApiKeyCatalog({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildModelStudioProvider,
allowExplicitBaseUrl: true,
}),
},
});
},

View File

@ -0,0 +1,102 @@
import type { ModelDefinitionConfig } from "../../src/config/types.models.js";
export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1";
export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus";
export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`;
export const MODELSTUDIO_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const MODELSTUDIO_MODEL_CATALOG = {
"qwen3.5-plus": {
name: "qwen3.5-plus",
reasoning: false,
input: ["text", "image"],
contextWindow: 1000000,
maxTokens: 65536,
},
"qwen3-max-2026-01-23": {
name: "qwen3-max-2026-01-23",
reasoning: false,
input: ["text"],
contextWindow: 262144,
maxTokens: 65536,
},
"qwen3-coder-next": {
name: "qwen3-coder-next",
reasoning: false,
input: ["text"],
contextWindow: 262144,
maxTokens: 65536,
},
"qwen3-coder-plus": {
name: "qwen3-coder-plus",
reasoning: false,
input: ["text"],
contextWindow: 1000000,
maxTokens: 65536,
},
"MiniMax-M2.5": {
name: "MiniMax-M2.5",
reasoning: false,
input: ["text"],
contextWindow: 1000000,
maxTokens: 65536,
},
"glm-5": {
name: "glm-5",
reasoning: false,
input: ["text"],
contextWindow: 202752,
maxTokens: 16384,
},
"glm-4.7": {
name: "glm-4.7",
reasoning: false,
input: ["text"],
contextWindow: 202752,
maxTokens: 16384,
},
"kimi-k2.5": {
name: "kimi-k2.5",
reasoning: false,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 32768,
},
} as const;
type ModelStudioCatalogId = keyof typeof MODELSTUDIO_MODEL_CATALOG;
export function buildModelStudioModelDefinition(params: {
id: string;
name?: string;
reasoning?: boolean;
input?: string[];
cost?: ModelDefinitionConfig["cost"];
contextWindow?: number;
maxTokens?: number;
}): ModelDefinitionConfig {
const catalog = MODELSTUDIO_MODEL_CATALOG[params.id as ModelStudioCatalogId];
return {
id: params.id,
name: params.name ?? catalog?.name ?? params.id,
reasoning: params.reasoning ?? catalog?.reasoning ?? false,
input:
(params.input as ("text" | "image")[]) ??
([...(catalog?.input ?? ["text"])] as ("text" | "image")[]),
cost: params.cost ?? MODELSTUDIO_DEFAULT_COST,
contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262144,
maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65536,
};
}
export function buildModelStudioDefaultModelDefinition(): ModelDefinitionConfig {
return buildModelStudioModelDefinition({
id: MODELSTUDIO_DEFAULT_MODEL_ID,
});
}

View File

@ -0,0 +1,61 @@
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithModelCatalog,
} from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
import {
MODELSTUDIO_CN_BASE_URL,
MODELSTUDIO_DEFAULT_MODEL_REF,
MODELSTUDIO_GLOBAL_BASE_URL,
} from "./model-definitions.js";
import { buildModelStudioProvider } from "./provider-catalog.js";
export { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL };
function applyModelStudioProviderConfigWithBaseUrl(
cfg: OpenClawConfig,
baseUrl: string,
): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
const provider = buildModelStudioProvider();
for (const model of provider.models ?? []) {
const modelRef = `modelstudio/${model.id}`;
if (!models[modelRef]) {
models[modelRef] = {};
}
}
models[MODELSTUDIO_DEFAULT_MODEL_REF] = {
...models[MODELSTUDIO_DEFAULT_MODEL_REF],
alias: models[MODELSTUDIO_DEFAULT_MODEL_REF]?.alias ?? "Qwen",
};
return applyProviderConfigWithModelCatalog(cfg, {
agentModels: models,
providerId: "modelstudio",
api: provider.api ?? "openai-completions",
baseUrl,
catalogModels: provider.models ?? [],
});
}
export function applyModelStudioProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_GLOBAL_BASE_URL);
}
export function applyModelStudioProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_CN_BASE_URL);
}
export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyModelStudioProviderConfig(cfg),
MODELSTUDIO_DEFAULT_MODEL_REF,
);
}
export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyModelStudioProviderConfigCn(cfg),
MODELSTUDIO_DEFAULT_MODEL_REF,
);
}

View File

@ -7,14 +7,16 @@ import {
getScopedCredentialValue,
setScopedCredentialValue,
} from "../../src/agents/tools/web-search-plugin-factory.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";
import {
applyMoonshotConfig,
applyMoonshotConfigCn,
} from "../../src/commands/onboard-auth.config-core.js";
import { MOONSHOT_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.models.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
MOONSHOT_DEFAULT_MODEL_REF,
} from "./onboard.js";
import { buildMoonshotProvider } from "./provider-catalog.js";
const PROVIDER_ID = "moonshot";
@ -34,8 +36,8 @@ const moonshotPlugin = {
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "Kimi API key (.ai)",
hint: "Kimi K2.5 + Kimi Coding",
label: "Moonshot API key (.ai)",
hint: "Kimi K2.5",
optionKey: "moonshotApiKey",
flagName: "--moonshot-api-key",
envVar: "MOONSHOT_API_KEY",
@ -45,17 +47,17 @@ const moonshotPlugin = {
applyConfig: (cfg) => applyMoonshotConfig(cfg),
wizard: {
choiceId: "moonshot-api-key",
choiceLabel: "Kimi API key (.ai)",
choiceLabel: "Moonshot API key (.ai)",
groupId: "moonshot",
groupLabel: "Moonshot AI (Kimi K2.5)",
groupHint: "Kimi K2.5 + Kimi Coding",
groupHint: "Kimi K2.5",
},
}),
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key-cn",
label: "Kimi API key (.cn)",
hint: "Kimi K2.5 + Kimi Coding",
label: "Moonshot API key (.cn)",
hint: "Kimi K2.5",
optionKey: "moonshotApiKey",
flagName: "--moonshot-api-key",
envVar: "MOONSHOT_API_KEY",
@ -65,31 +67,22 @@ const moonshotPlugin = {
applyConfig: (cfg) => applyMoonshotConfigCn(cfg),
wizard: {
choiceId: "moonshot-api-key-cn",
choiceLabel: "Kimi API key (.cn)",
choiceLabel: "Moonshot API key (.cn)",
groupId: "moonshot",
groupLabel: "Moonshot AI (Kimi K2.5)",
groupHint: "Kimi K2.5 + Kimi Coding",
groupHint: "Kimi K2.5",
},
}),
],
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID];
const explicitBaseUrl =
typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : "";
return {
provider: {
...buildMoonshotProvider(),
...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}),
apiKey,
},
};
},
run: (ctx) =>
buildSingleProviderApiKeyCatalog({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildMoonshotProvider,
allowExplicitBaseUrl: true,
}),
},
wrapStreamFn: (ctx) => {
const thinkingType = resolveMoonshotThinkingType({
@ -99,6 +92,7 @@ const moonshotPlugin = {
return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType);
},
});
api.registerMediaUnderstandingProvider(moonshotMediaUnderstandingProvider);
api.registerWebSearchProvider(
createPluginBackedWebSearchProvider({
id: "kimi",

View File

@ -1,5 +1,14 @@
import type { VideoDescriptionRequest, VideoDescriptionResult } from "../../types.js";
import { assertOkOrThrowHttpError, normalizeBaseUrl, postJsonRequest } from "../shared.js";
import { describeImageWithModel } from "../../src/media-understanding/providers/image.js";
import {
assertOkOrThrowHttpError,
normalizeBaseUrl,
postJsonRequest,
} from "../../src/media-understanding/providers/shared.js";
import type {
MediaUnderstandingProvider,
VideoDescriptionRequest,
VideoDescriptionResult,
} from "../../src/media-understanding/types.js";
export const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1";
const DEFAULT_MOONSHOT_VIDEO_MODEL = "kimi-k2.5";
@ -104,3 +113,10 @@ export async function describeMoonshotVideo(
await release();
}
}
export const moonshotMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "moonshot",
capabilities: ["image", "video"],
describeImage: describeImageWithModel,
describeVideo: describeMoonshotVideo,
};

View File

@ -0,0 +1,60 @@
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithDefaultModel,
} from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
import {
buildMoonshotProvider,
MOONSHOT_BASE_URL,
MOONSHOT_DEFAULT_MODEL_ID,
} from "./provider-catalog.js";
export const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1";
export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`;
export function applyMoonshotProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_BASE_URL);
}
export function applyMoonshotProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_CN_BASE_URL);
}
function applyMoonshotProviderConfigWithBaseUrl(
cfg: OpenClawConfig,
baseUrl: string,
): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[MOONSHOT_DEFAULT_MODEL_REF] = {
...models[MOONSHOT_DEFAULT_MODEL_REF],
alias: models[MOONSHOT_DEFAULT_MODEL_REF]?.alias ?? "Kimi",
};
const defaultModel = buildMoonshotProvider().models[0];
if (!defaultModel) {
return cfg;
}
return applyProviderConfigWithDefaultModel(cfg, {
agentModels: models,
providerId: "moonshot",
api: "openai-completions",
baseUrl,
defaultModel,
defaultModelId: MOONSHOT_DEFAULT_MODEL_ID,
});
}
export function applyMoonshotConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyMoonshotProviderConfig(cfg),
MOONSHOT_DEFAULT_MODEL_REF,
);
}
export function applyMoonshotConfigCn(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyMoonshotProviderConfigCn(cfg),
MOONSHOT_DEFAULT_MODEL_REF,
);
}

View File

@ -9,10 +9,10 @@
"provider": "moonshot",
"method": "api-key",
"choiceId": "moonshot-api-key",
"choiceLabel": "Kimi API key (.ai)",
"choiceLabel": "Moonshot API key (.ai)",
"groupId": "moonshot",
"groupLabel": "Moonshot AI (Kimi K2.5)",
"groupHint": "Kimi K2.5 + Kimi Coding",
"groupHint": "Kimi K2.5",
"optionKey": "moonshotApiKey",
"cliFlag": "--moonshot-api-key",
"cliOption": "--moonshot-api-key <key>",
@ -22,10 +22,10 @@
"provider": "moonshot",
"method": "api-key-cn",
"choiceId": "moonshot-api-key-cn",
"choiceLabel": "Kimi API key (.cn)",
"choiceLabel": "Moonshot API key (.cn)",
"groupId": "moonshot",
"groupLabel": "Moonshot AI (Kimi K2.5)",
"groupHint": "Kimi K2.5 + Kimi Coding",
"groupHint": "Kimi K2.5",
"optionKey": "moonshotApiKey",
"cliFlag": "--moonshot-api-key",
"cliOption": "--moonshot-api-key <key>",

View File

@ -1,5 +1,4 @@
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { DEFAULT_ACCOUNT_ID, type ChannelSetupAdapter } from "openclaw/plugin-sdk/setup";
export const msteamsSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: () => DEFAULT_ACCOUNT_ID,

View File

@ -1,17 +1,18 @@
import type { MSTeamsTeamConfig } from "openclaw/plugin-sdk/msteams";
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
setTopLevelChannelAllowFrom,
setTopLevelChannelDmPolicyWithAllowFrom,
setTopLevelChannelGroupPolicy,
splitSetupEntries,
} from "../../../src/channels/plugins/setup-wizard-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import {
parseMSTeamsTeamEntry,
resolveMSTeamsChannelAllowlist,

View File

@ -1,6 +1,6 @@
import {
applyAccountNameToChannelSection,
patchScopedAccountConfig,
prepareScopedSetupConfig,
} from "../../../src/channels/plugins/setup-helpers.js";
import {
mergeAllowFromEntries,
@ -115,7 +115,7 @@ export function clearNextcloudTalkAccountFields(
} as CoreConfig;
}
async function promptNextcloudTalkAllowFrom(params: {
export async function promptNextcloudTalkAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
@ -127,7 +127,7 @@ async function promptNextcloudTalkAllowFrom(params: {
"1) Check the Nextcloud admin panel for user IDs",
"2) Or look at the webhook payload logs when someone messages",
"3) User IDs are typically lowercase usernames in Nextcloud",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "nextcloud-talk")}`,
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
].join("\n"),
"Nextcloud Talk user id",
);
@ -158,7 +158,7 @@ async function promptNextcloudTalkAllowFrom(params: {
});
}
async function promptNextcloudTalkAllowFromForAccount(params: {
export async function promptNextcloudTalkAllowFromForAccount(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
@ -174,7 +174,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: {
});
}
const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
label: "Nextcloud Talk",
channel,
policyKey: "channels.nextcloud-talk.dmPolicy",
@ -187,7 +187,7 @@ const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
prepareScopedSetupConfig({
cfg,
channelKey: channel,
accountId,
@ -208,7 +208,7 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const setupInput = input as NextcloudSetupInput;
const namedConfig = applyAccountNameToChannelSection({
const namedConfig = prepareScopedSetupConfig({
cfg,
channelKey: channel,
accountId,

View File

@ -1,111 +1,22 @@
import {
mergeAllowFromEntries,
resolveSetupAccountId,
setSetupChannelEnabled,
setTopLevelChannelDmPolicyWithAllowFrom,
} from "../../../src/channels/plugins/setup-wizard-helpers.js";
import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js";
import { setSetupChannelEnabled } from "../../../src/channels/plugins/setup-wizard-helpers.js";
import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js";
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import {
listNextcloudTalkAccountIds,
resolveDefaultNextcloudTalkAccountId,
resolveNextcloudTalkAccount,
} from "./accounts.js";
import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js";
import {
clearNextcloudTalkAccountFields,
nextcloudTalkDmPolicy,
nextcloudTalkSetupAdapter,
normalizeNextcloudTalkBaseUrl,
setNextcloudTalkAccountConfig,
validateNextcloudTalkBaseUrl,
} from "./setup-core.js";
import type { CoreConfig, DmPolicy } from "./types.js";
import type { CoreConfig } from "./types.js";
const channel = "nextcloud-talk" as const;
const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials";
function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy,
}) as CoreConfig;
}
async function promptNextcloudTalkAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
await params.prompter.note(
[
"1) Check the Nextcloud admin panel for user IDs",
"2) Or look at the webhook payload logs when someone messages",
"3) User IDs are typically lowercase usernames in Nextcloud",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
].join("\n"),
"Nextcloud Talk user id",
);
let resolvedIds: string[] = [];
while (resolvedIds.length === 0) {
const entry = await params.prompter.text({
message: "Nextcloud Talk allowFrom (user id)",
placeholder: "username",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
resolvedIds = String(entry)
.split(/[\n,;]+/g)
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
if (resolvedIds.length === 0) {
await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk");
}
}
return setNextcloudTalkAccountConfig(params.cfg, params.accountId, {
dmPolicy: "allowlist",
allowFrom: mergeAllowFromEntries(
existingAllowFrom.map((value) => String(value).trim().toLowerCase()),
resolvedIds,
),
});
}
async function promptNextcloudTalkAllowFromForAccount(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig),
});
return await promptNextcloudTalkAllowFrom({
cfg: params.cfg as CoreConfig,
prompter: params.prompter,
accountId,
});
}
const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
label: "Nextcloud Talk",
channel,
policyKey: "channels.nextcloud-talk.dmPolicy",
allowFromKey: "channels.nextcloud-talk.allowFrom",
getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy),
promptAllowFrom: promptNextcloudTalkAllowFromForAccount,
};
export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
channel,
stepOrder: "text-first",

View File

@ -1,4 +1,5 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js";
import { buildNvidiaProvider } from "./provider-catalog.js";
const PROVIDER_ID = "nvidia";
@ -17,18 +18,12 @@ const nvidiaPlugin = {
auth: [],
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
return {
provider: {
...buildNvidiaProvider(),
apiKey,
},
};
},
run: (ctx) =>
buildSingleProviderApiKeyCatalog({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildNvidiaProvider,
}),
},
});
},

View File

@ -1,5 +1,6 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { buildOpenAISpeechProvider } from "../../src/tts/providers/openai.js";
import { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js";
import { buildOpenAIProvider } from "./openai-provider.js";
@ -12,6 +13,7 @@ const openAIPlugin = {
api.registerProvider(buildOpenAIProvider());
api.registerProvider(buildOpenAICodexProviderPlugin());
api.registerSpeechProvider(buildOpenAISpeechProvider());
api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider);
},
};

View File

@ -0,0 +1,23 @@
import { describeImageWithModel } from "../../src/media-understanding/providers/image.js";
import { transcribeOpenAiCompatibleAudio } from "../../src/media-understanding/providers/openai-compatible-audio.js";
import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js";
export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1";
const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe";
export async function transcribeOpenAiAudio(
params: import("../../src/media-understanding/types.js").AudioTranscriptionRequest,
) {
return await transcribeOpenAiCompatibleAudio({
...params,
defaultBaseUrl: DEFAULT_OPENAI_AUDIO_BASE_URL,
defaultModel: DEFAULT_OPENAI_AUDIO_MODEL,
});
}
export const openaiMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "openai",
capabilities: ["image", "audio"],
describeImage: describeImageWithModel,
transcribeAudio: transcribeOpenAiAudio,
};

View File

@ -4,6 +4,7 @@ import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/core";
import { CODEX_CLI_PROFILE_ID } from "../../src/agents/auth-profiles.js";
import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js";
import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js";
@ -13,7 +14,6 @@ import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { normalizeProviderId } from "../../src/agents/provider-id.js";
import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js";
import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js";
import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js";
import type { ProviderPlugin } from "../../src/plugins/types.js";
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
import {

View File

@ -1,4 +1,5 @@
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { findCatalogTemplate } from "../../src/plugins/provider-catalog.js";
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
@ -48,18 +49,4 @@ export function cloneFirstTemplateModel(params: {
return undefined;
}
export function findCatalogTemplate(params: {
entries: ReadonlyArray<{ provider: string; id: string }>;
providerId: string;
templateIds: readonly string[];
}) {
return params.templateIds
.map((templateId) =>
params.entries.find(
(entry) =>
entry.provider.toLowerCase() === params.providerId.toLowerCase() &&
entry.id.toLowerCase() === templateId.toLowerCase(),
),
)
.find((entry) => entry !== undefined);
}
export { findCatalogTemplate };

View File

@ -1,7 +1,7 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { applyOpencodeGoConfig } from "../../src/commands/onboard-auth.config-opencode-go.js";
import { OPENCODE_GO_DEFAULT_MODEL_REF } from "../../src/commands/opencode-go-model-default.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { applyOpencodeGoConfig } from "./onboard.js";
const PROVIDER_ID = "opencode-go";

View File

@ -0,0 +1,39 @@
import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js";
import { OPENCODE_GO_DEFAULT_MODEL_REF } from "../../src/commands/opencode-go-model-default.js";
import type { OpenClawConfig } from "../../src/config/config.js";
export { OPENCODE_GO_DEFAULT_MODEL_REF };
const OPENCODE_GO_ALIAS_DEFAULTS: Record<string, string> = {
"opencode-go/kimi-k2.5": "Kimi",
"opencode-go/glm-5": "GLM",
"opencode-go/minimax-m2.5": "MiniMax",
};
export function applyOpencodeGoProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
for (const [modelRef, alias] of Object.entries(OPENCODE_GO_ALIAS_DEFAULTS)) {
models[modelRef] = {
...models[modelRef],
alias: models[modelRef]?.alias ?? alias,
};
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
export function applyOpencodeGoConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyOpencodeGoProviderConfig(cfg),
OPENCODE_GO_DEFAULT_MODEL_REF,
);
}

View File

@ -1,7 +1,7 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { applyOpencodeZenConfig } from "../../src/commands/onboard-auth.config-opencode.js";
import { OPENCODE_ZEN_DEFAULT_MODEL } from "../../src/commands/opencode-zen-model-default.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { applyOpencodeZenConfig } from "./onboard.js";
const PROVIDER_ID = "opencode";
const MINIMAX_PREFIX = "minimax-m2.5";

View File

@ -0,0 +1,31 @@
import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../../src/agents/opencode-zen-models.js";
import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
export { OPENCODE_ZEN_DEFAULT_MODEL_REF };
export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = {
...models[OPENCODE_ZEN_DEFAULT_MODEL_REF],
alias: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus",
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyOpencodeZenProviderConfig(cfg),
OPENCODE_ZEN_DEFAULT_MODEL_REF,
);
}

View File

@ -15,11 +15,8 @@ import {
createOpenRouterWrapper,
isProxyReasoningUnsupported,
} from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js";
import {
applyOpenrouterConfig,
OPENROUTER_DEFAULT_MODEL_REF,
} from "../../src/commands/onboard-auth.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildOpenrouterProvider } from "./provider-catalog.js";
const PROVIDER_ID = "openrouter";

View File

@ -0,0 +1,30 @@
import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[OPENROUTER_DEFAULT_MODEL_REF] = {
...models[OPENROUTER_DEFAULT_MODEL_REF],
alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter",
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
};
}
export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyOpenrouterProviderConfig(cfg),
OPENROUTER_DEFAULT_MODEL_REF,
);
}

View File

@ -1,6 +1,7 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js";
import { buildQianfanProvider } from "./provider-catalog.js";
const PROVIDER_ID = "qianfan";
@ -40,18 +41,12 @@ const qianfanPlugin = {
],
catalog: {
order: "simple",
run: async (ctx) => {
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
if (!apiKey) {
return null;
}
return {
provider: {
...buildQianfanProvider(),
apiKey,
},
};
},
run: (ctx) =>
buildSingleProviderApiKeyCatalog({
ctx,
providerId: PROVIDER_ID,
buildProvider: buildQianfanProvider,
}),
},
});
},

View File

@ -0,0 +1,48 @@
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithDefaultModels,
} from "../../src/commands/onboard-auth.config-shared.js";
import type { OpenClawConfig } from "../../src/config/config.js";
import type { ModelApi } from "../../src/config/types.models.js";
import {
buildQianfanProvider,
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_ID,
} from "./provider-catalog.js";
export const QIANFAN_DEFAULT_MODEL_REF = `qianfan/${QIANFAN_DEFAULT_MODEL_ID}`;
export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[QIANFAN_DEFAULT_MODEL_REF] = {
...models[QIANFAN_DEFAULT_MODEL_REF],
alias: models[QIANFAN_DEFAULT_MODEL_REF]?.alias ?? "QIANFAN",
};
const defaultProvider = buildQianfanProvider();
const existingProvider = cfg.models?.providers?.qianfan as
| {
baseUrl?: unknown;
api?: unknown;
}
| undefined;
const existingBaseUrl =
typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : "";
const resolvedBaseUrl = existingBaseUrl || QIANFAN_BASE_URL;
const resolvedApi =
typeof existingProvider?.api === "string"
? (existingProvider.api as ModelApi)
: "openai-completions";
return applyProviderConfigWithDefaultModels(cfg, {
agentModels: models,
providerId: "qianfan",
api: resolvedApi,
baseUrl: resolvedBaseUrl,
defaultModels: defaultProvider.models ?? [],
defaultModelId: QIANFAN_DEFAULT_MODEL_ID,
});
}
export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(applyQianfanProviderConfig(cfg), QIANFAN_DEFAULT_MODEL_REF);
}

View File

@ -1,10 +1,10 @@
import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal";
import {
type OpenClawConfig,
createAccountListHelpers,
normalizeAccountId,
resolveAccountEntry,
} from "../../../src/plugin-sdk-internal/accounts.js";
import type { SignalAccountConfig } from "../../../src/plugin-sdk-internal/signal.js";
export type ResolvedSignalAccount = {
accountId: string;

View File

@ -1,114 +1,9 @@
import {
createScopedAccountConfigAccessors,
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
normalizeE164,
setAccountEnabledInConfigSection,
SignalConfigSchema,
type ChannelPlugin,
} from "openclaw/plugin-sdk/signal";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
type ResolvedSignalAccount,
} from "./accounts.js";
import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js";
import { type ChannelPlugin } from "openclaw/plugin-sdk/signal";
import { type ResolvedSignalAccount } from "./accounts.js";
import { signalSetupAdapter } from "./setup-core.js";
import { createSignalPluginBase, signalSetupWizard } from "./shared.js";
async function loadSignalChannelRuntime() {
return await import("./channel.runtime.js");
}
const signalSetupWizard = createSignalSetupWizardProxy(async () => ({
signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard,
}));
const signalConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
.filter(Boolean),
resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo,
});
export const signalSetupPlugin: ChannelPlugin<ResolvedSignalAccount> = {
id: "signal",
meta: {
...getChatChannelMeta("signal"),
},
export const signalSetupPlugin: ChannelPlugin<ResolvedSignalAccount> = createSignalPluginBase({
setupWizard: signalSetupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.signal"] },
configSchema: buildChannelConfigSchema(SignalConfigSchema),
config: {
listAccountIds: (cfg) => listSignalAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "signal",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "signal",
accountId,
clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
...signalConfigAccessors,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) =>
buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "signal",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
}),
collectWarnings: ({ account, cfg }) =>
collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.signal !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
surface: "Signal groups",
openScope: "any member",
groupPolicyPath: "channels.signal.groupPolicy",
groupAllowFromPath: "channels.signal.groupAllowFrom",
mentionGated: false,
}),
},
setup: signalSetupAdapter,
};
});

View File

@ -1,26 +1,15 @@
import {
buildAccountScopedAllowlistConfigEditor,
buildAccountScopedDmSecurityPolicy,
createScopedAccountConfigAccessors,
collectAllowlistProviderRestrictSendersWarnings,
} from "openclaw/plugin-sdk/compat";
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat";
import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core";
import {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
buildChannelConfigSchema,
collectStatusIssuesFromLastError,
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
looksLikeSignalTargetId,
normalizeE164,
normalizeSignalMessagingTarget,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
setAccountEnabledInConfigSection,
SignalConfigSchema,
type ChannelMessageActionAdapter,
type ChannelPlugin,
} from "openclaw/plugin-sdk/signal";
@ -42,15 +31,8 @@ import {
} from "./identity.js";
import type { SignalProbe } from "./probe.js";
import { getSignalRuntime } from "./runtime.js";
import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js";
async function loadSignalChannelRuntime() {
return await import("./channel.runtime.js");
}
const signalSetupWizard = createSignalSetupWizardProxy(async () => ({
signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard,
}));
import { signalSetupAdapter } from "./setup-core.js";
import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js";
const signalMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [],
@ -65,18 +47,6 @@ const signalMessageActions: ChannelMessageActionAdapter = {
},
};
const signalConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
.filter(Boolean),
resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo,
});
type SignalSendFn = ReturnType<typeof getSignalRuntime>["channel"]["signal"]["sendMessageSignal"];
function resolveSignalSendContext(params: {
@ -312,11 +282,10 @@ async function sendFormattedSignalMedia(ctx: {
}
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
id: "signal",
meta: {
...getChatChannelMeta("signal"),
},
setupWizard: signalSetupWizard,
...createSignalPluginBase({
setupWizard: signalSetupWizard,
setup: signalSetupAdapter,
}),
pairing: {
idLabel: "signalNumber",
normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""),
@ -324,46 +293,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
},
actions: signalMessageActions,
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.signal"] },
configSchema: buildChannelConfigSchema(SignalConfigSchema),
config: {
listAccountIds: (cfg) => listSignalAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "signal",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "signal",
accountId,
clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
...signalConfigAccessors,
},
allowlist: {
supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all",
readConfig: ({ cfg, accountId }) => {
@ -385,32 +315,6 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
}),
}),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: "signal",
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
});
},
collectWarnings: ({ account, cfg }) => {
return collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.signal !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
surface: "Signal groups",
openScope: "any member",
groupPolicyPath: "channels.signal.groupPolicy",
groupAllowFromPath: "channels.signal.groupAllowFrom",
mentionGated: false,
});
},
},
messaging: {
normalizeTarget: normalizeSignalMessagingTarget,
parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw),
@ -421,7 +325,6 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
},
},
setup: signalSetupAdapter,
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),

View File

@ -1,4 +1,4 @@
import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk-internal/signal.js";
import { normalizeE164 } from "../../../src/utils.js";
export type SignalSender =

View File

@ -0,0 +1,25 @@
import { createScopedAccountConfigAccessors } from "../../../src/plugin-sdk-internal/channel-config.js";
import { normalizeE164, type OpenClawConfig } from "../../../src/plugin-sdk-internal/signal.js";
import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js";
import { createSignalSetupWizardProxy } from "./setup-core.js";
async function loadSignalChannelRuntime() {
return await import("./channel.runtime.js");
}
export const signalSetupWizard = createSignalSetupWizardProxy(async () => ({
signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard,
}));
export const signalConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) =>
resolveSignalAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
.filter(Boolean),
resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo,
});

View File

@ -1,5 +1,7 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import {
createPluginRuntimeStore,
type PluginRuntime,
} from "../../../src/plugin-sdk-internal/core.js";
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");

View File

@ -1,10 +1,5 @@
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
import {
applyAccountNameToChannelSection,
DEFAULT_ACCOUNT_ID,
formatCliCommand,
formatDocsLink,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
normalizeE164,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForScopedChannel,
@ -18,6 +13,7 @@ import type {
ChannelSetupDmPolicy,
ChannelSetupWizard,
} from "../../../src/plugin-sdk-internal/setup.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
@ -28,7 +24,7 @@ const channel = "signal" as const;
const MIN_E164_DIGITS = 5;
const MAX_E164_DIGITS = 15;
const DIGITS_ONLY = /^\d+$/;
const INVALID_SIGNAL_ACCOUNT_ERROR =
export const INVALID_SIGNAL_ACCOUNT_ERROR =
"Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)";
export function normalizeSignalAccountInput(value: string | null | undefined): string | null {
@ -87,7 +83,7 @@ function buildSignalSetupPatch(input: {
};
}
async function promptSignalAllowFrom(params: {
export async function promptSignalAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
@ -115,15 +111,8 @@ async function promptSignalAllowFrom(params: {
});
}
export const signalSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: channel,
validateInput: ({ input }) => {
if (
!input.signalNumber &&
@ -136,74 +125,40 @@ export const signalSetupAdapter: ChannelSetupAdapter = {
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
...buildSignalSetupPatch(input),
},
},
};
}
return {
...next,
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
accounts: {
...next.channels?.signal?.accounts,
[accountId]: {
...next.channels?.signal?.accounts?.[accountId],
enabled: true,
...buildSignalSetupPatch(input),
},
},
},
},
};
},
buildPatch: (input) => buildSignalSetupPatch(input),
});
type SignalSetupWizardHandlers = {
resolveStatusLines: NonNullable<ChannelSetupWizard["status"]>["resolveStatusLines"];
resolveSelectionHint: NonNullable<ChannelSetupWizard["status"]>["resolveSelectionHint"];
resolveQuickstartScore: NonNullable<ChannelSetupWizard["status"]>["resolveQuickstartScore"];
prepare?: ChannelSetupWizard["prepare"];
shouldPromptCliPath: NonNullable<
NonNullable<ChannelSetupWizard["textInputs"]>[number]["shouldPrompt"]
>;
};
export function createSignalSetupWizardProxy(
loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>,
) {
export function createSignalSetupWizardBase(
handlers: SignalSetupWizardHandlers,
): ChannelSetupWizard {
const setupChannel = "signal" as const;
const signalDmPolicy: ChannelSetupDmPolicy = {
label: "Signal",
channel,
channel: setupChannel,
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg: OpenClawConfig, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel,
channel: setupChannel,
dmPolicy: policy,
}),
promptAllowFrom: promptSignalAllowFrom,
};
return {
channel,
channel: setupChannel,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
@ -215,14 +170,11 @@ export function createSignalSetupWizardProxy(
listSignalAccountIds(cfg).some(
(accountId) => resolveSignalAccount({ cfg, accountId }).configured,
),
resolveStatusLines: async (params) =>
(await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [],
resolveSelectionHint: async (params) =>
await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params),
resolveQuickstartScore: async (params) =>
await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params),
resolveStatusLines: handlers.resolveStatusLines,
resolveSelectionHint: handlers.resolveSelectionHint,
resolveQuickstartScore: handlers.resolveQuickstartScore,
},
prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params),
prepare: handlers.prepare,
credentials: [],
textInputs: [
{
@ -236,12 +188,7 @@ export function createSignalSetupWizardProxy(
(typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ??
resolveSignalAccount({ cfg, accountId }).config.cliPath ??
"signal-cli",
shouldPrompt: async (params) => {
const input = (await loadWizard()).signalSetupWizard.textInputs?.find(
(entry) => entry.inputKey === "cliPath",
);
return (await input?.shouldPrompt?.(params)) ?? false;
},
shouldPrompt: handlers.shouldPromptCliPath,
confirmCurrentValue: false,
applyCurrentValue: true,
helpTitle: "Signal",
@ -266,11 +213,31 @@ export function createSignalSetupWizardProxy(
lines: [
'Link device with: signal-cli link -n "OpenClaw"',
"Scan QR in Signal -> Linked Devices",
`Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`,
`Docs: ${formatDocsLink("/signal", "signal")}`,
`Then run: openclaw gateway call channels.status --params '{"probe":true}'`,
"Docs: https://docs.openclaw.ai/signal",
],
},
dmPolicy: signalDmPolicy,
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, setupChannel, false),
} satisfies ChannelSetupWizard;
}
export function createSignalSetupWizardProxy(
loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>,
) {
return createSignalSetupWizardBase({
resolveStatusLines: async (params) =>
(await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [],
resolveSelectionHint: async (params) =>
await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params),
resolveQuickstartScore: async (params) =>
await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params),
prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params),
shouldPromptCliPath: async (params) => {
const input = (await loadWizard()).signalSetupWizard.textInputs?.find(
(entry) => entry.inputKey === "cliPath",
);
return (await input?.shouldPrompt?.(params)) ?? false;
},
});
}

View File

@ -1,107 +1,34 @@
import {
DEFAULT_ACCOUNT_ID,
detectBinary,
formatCliCommand,
formatDocsLink,
installSignalCli,
type OpenClawConfig,
parseSetupEntriesAllowingWildcard,
promptParsedAllowFromForScopedChannel,
setChannelDmPolicyWithAllowFrom,
setSetupChannelEnabled,
type WizardPrompter,
} from "../../../src/plugin-sdk-internal/setup.js";
import type {
ChannelSetupDmPolicy,
ChannelSetupWizard,
} from "../../../src/plugin-sdk-internal/setup.js";
import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js";
import { resolveSignalAccount } from "./accounts.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
} from "./accounts.js";
import {
createSignalSetupWizardBase,
INVALID_SIGNAL_ACCOUNT_ERROR,
normalizeSignalAccountInput,
parseSignalAllowFromEntries,
promptSignalAllowFrom,
signalSetupAdapter,
} from "./setup-core.js";
const channel = "signal" as const;
const INVALID_SIGNAL_ACCOUNT_ERROR =
"Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)";
async function promptSignalAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
return promptParsedAllowFromForScopedChannel({
cfg: params.cfg,
channel,
accountId: params.accountId,
defaultAccountId: resolveDefaultSignalAccountId(params.cfg),
prompter: params.prompter,
noteTitle: "Signal allowlist",
noteLines: [
"Allowlist Signal DMs by sender id.",
"Examples:",
"- +15555550123",
"- uuid:123e4567-e89b-12d3-a456-426614174000",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/signal", "signal")}`,
],
message: "Signal allowFrom (E.164 or uuid)",
placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000",
parseEntries: parseSignalAllowFromEntries,
getExistingAllowFrom: ({ cfg, accountId }) =>
resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [],
});
}
const signalDmPolicy: ChannelSetupDmPolicy = {
label: "Signal",
channel,
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) =>
setChannelDmPolicyWithAllowFrom({
cfg,
channel,
dmPolicy: policy,
}),
promptAllowFrom: promptSignalAllowFrom,
};
export const signalSetupWizard: ChannelSetupWizard = {
channel,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "signal-cli found",
unconfiguredHint: "signal-cli missing",
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: ({ cfg }) =>
listSignalAccountIds(cfg).some(
(accountId) => resolveSignalAccount({ cfg, accountId }).configured,
),
resolveStatusLines: async ({ cfg, configured }) => {
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
const signalCliDetected = await detectBinary(signalCliPath);
return [
`Signal: ${configured ? "configured" : "needs setup"}`,
`signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`,
];
},
resolveSelectionHint: async ({ cfg }) => {
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing";
},
resolveQuickstartScore: async ({ cfg }) => {
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
return (await detectBinary(signalCliPath)) ? 1 : 0;
},
export const signalSetupWizard: ChannelSetupWizard = createSignalSetupWizardBase({
resolveStatusLines: async ({ cfg, configured }) => {
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
const signalCliDetected = await detectBinary(signalCliPath);
return [
`Signal: ${configured ? "configured" : "needs setup"}`,
`signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`,
];
},
resolveSelectionHint: async ({ cfg }) => {
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing";
},
resolveQuickstartScore: async ({ cfg }) => {
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
return (await detectBinary(signalCliPath)) ? 1 : 0;
},
prepare: async ({ cfg, accountId, credentialValues, runtime, prompter, options }) => {
if (!options?.allowSignalInstall) {
@ -138,50 +65,13 @@ export const signalSetupWizard: ChannelSetupWizard = {
await prompter.note(`signal-cli install failed: ${String(error)}`, "Signal");
}
},
credentials: [],
textInputs: [
{
inputKey: "cliPath",
message: "signal-cli path",
currentValue: ({ cfg, accountId, credentialValues }) =>
(typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ??
resolveSignalAccount({ cfg, accountId }).config.cliPath ??
"signal-cli",
initialValue: ({ cfg, accountId, credentialValues }) =>
(typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ??
resolveSignalAccount({ cfg, accountId }).config.cliPath ??
"signal-cli",
shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "signal-cli")),
confirmCurrentValue: false,
applyCurrentValue: true,
helpTitle: "Signal",
helpLines: [
"signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.",
],
},
{
inputKey: "signalNumber",
message: "Signal bot number (E.164)",
currentValue: ({ cfg, accountId }) =>
normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ??
undefined,
keepPrompt: (value) => `Signal account set (${value}). Keep it?`,
validate: ({ value }) =>
normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR,
normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value,
},
],
completionNote: {
title: "Signal next steps",
lines: [
'Link device with: signal-cli link -n "OpenClaw"',
"Scan QR in Signal -> Linked Devices",
`Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`,
`Docs: ${formatDocsLink("/signal", "signal")}`,
],
},
dmPolicy: signalDmPolicy,
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
shouldPromptCliPath: async ({ currentValue }) =>
!(await detectBinary(currentValue ?? "signal-cli")),
});
export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter };
export {
INVALID_SIGNAL_ACCOUNT_ERROR,
normalizeSignalAccountInput,
promptSignalAllowFrom,
signalSetupAdapter,
};

View File

@ -0,0 +1,133 @@
import {
buildAccountScopedDmSecurityPolicy,
collectAllowlistProviderRestrictSendersWarnings,
createScopedAccountConfigAccessors,
} from "openclaw/plugin-sdk/compat";
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
normalizeE164,
setAccountEnabledInConfigSection,
SignalConfigSchema,
type ChannelPlugin,
} from "openclaw/plugin-sdk/signal";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
type ResolvedSignalAccount,
} from "./accounts.js";
import { createSignalSetupWizardProxy } from "./setup-core.js";
export const SIGNAL_CHANNEL = "signal" as const;
async function loadSignalChannelRuntime() {
return await import("./channel.runtime.js");
}
export const signalSetupWizard = createSignalSetupWizardProxy(async () => ({
signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard,
}));
export const signalConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, ""))))
.filter(Boolean),
resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo,
});
export function createSignalPluginBase(params: {
setupWizard?: NonNullable<ChannelPlugin<ResolvedSignalAccount>["setupWizard"]>;
setup: NonNullable<ChannelPlugin<ResolvedSignalAccount>["setup"]>;
}): Pick<
ChannelPlugin<ResolvedSignalAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "streaming"
| "reload"
| "configSchema"
| "config"
| "security"
| "setup"
> {
return {
id: SIGNAL_CHANNEL,
meta: {
...getChatChannelMeta(SIGNAL_CHANNEL),
},
setupWizard: params.setupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.signal"] },
configSchema: buildChannelConfigSchema(SignalConfigSchema),
config: {
listAccountIds: (cfg) => listSignalAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: SIGNAL_CHANNEL,
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: SIGNAL_CHANNEL,
accountId,
clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
...signalConfigAccessors,
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) =>
buildAccountScopedDmSecurityPolicy({
cfg,
channelKey: SIGNAL_CHANNEL,
accountId,
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
policy: account.config.dmPolicy,
allowFrom: account.config.allowFrom ?? [],
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()),
}),
collectWarnings: ({ account, cfg }) =>
collectAllowlistProviderRestrictSendersWarnings({
cfg,
providerConfigPresent: cfg.channels?.signal !== undefined,
configuredGroupPolicy: account.config.groupPolicy,
surface: "Signal groups",
openScope: "any member",
groupPolicyPath: "channels.signal.groupPolicy",
groupAllowFromPath: "channels.signal.groupAllowFrom",
mentionGated: false,
}),
},
setup: params.setup,
};
}

View File

@ -1,13 +1,13 @@
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../../../src/config/types.secrets.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type OpenClawConfig,
type SlackAccountConfig,
} from "openclaw/plugin-sdk/slack";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../../../src/config/types.secrets.js";
} from "../../../src/plugin-sdk-internal/slack.js";
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
import {
mergeSlackAccountConfig,

View File

@ -1,4 +1,3 @@
import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack";
import {
type OpenClawConfig,
createAccountListHelpers,
@ -7,6 +6,7 @@ import {
normalizeChatType,
resolveAccountEntry,
} from "../../../src/plugin-sdk-internal/accounts.js";
import type { SlackAccountConfig } from "../../../src/plugin-sdk-internal/slack.js";
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";

Some files were not shown because too many files have changed in this diff Show More