From 7acd26a2fc548fbe42ada22430077425db52120f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 11:45:25 +0000 Subject: [PATCH] Move provider to a plugin-architecture (#661) * refactor: introduce provider plugin registry * refactor: move provider CLI to plugins * docs: add provider plugin implementation notes * refactor: shift provider runtime logic into plugins * refactor: add plugin defaults and summaries * docs: update provider plugin notes * feat(commands): add /commands slash list * Auto-reply: tidy help message * Auto-reply: fix status command lint * Tests: align google shared expectations * Auto-reply: tidy help message * Auto-reply: fix status command lint * refactor: move provider routing into plugins * test: align agent routing expectations * docs: update provider plugin notes * refactor: route replies via provider plugins * docs: note route-reply plugin hooks * refactor: extend provider plugin contract * refactor: derive provider status from plugins * refactor: unify gateway provider control * refactor: use plugin metadata in auto-reply * fix: parenthesize cron target selection * refactor: derive gateway methods from plugins * refactor: generalize provider logout * refactor: route provider logout through plugins * refactor: move WhatsApp web login methods into plugin * refactor: generalize provider log prefixes * refactor: centralize default chat provider * refactor: derive provider lists from registry * refactor: move provider reload noops into plugins * refactor: resolve web login provider via alias * refactor: derive CLI provider options from plugins * refactor: derive prompt provider list from plugins * style: apply biome lint fixes * fix: resolve provider routing edge cases * docs: update provider plugin refactor notes * fix(gateway): harden agent provider routing * refactor: move provider routing into plugins * refactor: move provider CLI to plugins * refactor: derive provider lists from registry * fix: restore slash command parsing * refactor: align provider ids for schema * refactor: unify outbound target resolution * fix: keep outbound labels stable * feat: add msteams to cron surfaces * fix: clean up lint build issues * refactor: localize chat provider alias normalization * refactor: drive gateway provider lists from plugins * docs: update provider plugin notes * style: format message-provider * fix: avoid provider registry init cycles * style: sort message-provider imports * fix: relax provider alias map typing * refactor: move provider routing into plugins * refactor: add plugin pairing/config adapters * refactor: route pairing and provider removal via plugins * refactor: align auto-reply provider typing * test: stabilize telegram media mocks * docs: update provider plugin refactor notes * refactor: pluginize outbound targets * refactor: pluginize provider selection * refactor: generalize text chunk limits * docs: update provider plugin notes * refactor: generalize group session/config * fix: normalize provider id for room detection * fix: avoid provider init in system prompt * style: formatting cleanup * refactor: normalize agent delivery targets * test: update outbound delivery labels * chore: fix lint regressions * refactor: extend provider plugin adapters * refactor: move elevated/block streaming defaults to plugins * refactor: defer outbound send deps to plugins * docs: note plugin-driven streaming/elevated defaults * refactor: centralize webchat provider constant * refactor: add provider setup adapters * refactor: delegate provider add config to plugins * docs: document plugin-driven provider add * refactor: add plugin state/binding metadata * refactor: build agent provider status from plugins * docs: note plugin-driven agent bindings * refactor: centralize internal provider constant usage * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize default chat provider * refactor: centralize WhatsApp target normalization * refactor: move provider routing into plugins * refactor: normalize agent delivery targets * chore: fix lint regressions * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * feat: expand provider plugin adapters * refactor: route auto-reply via provider plugins * fix: align WhatsApp target normalization * fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing) * refactor: centralize WhatsApp target normalization * feat: add /config chat config updates * docs: add /config get alias * feat(commands): add /commands slash list * refactor: centralize default chat provider * style: apply biome lint fixes * chore: fix lint regressions * fix: clean up whatsapp allowlist typing * style: format config command helpers * refactor: pluginize tool threading context * refactor: normalize session announce targets * docs: note new plugin threading and announce hooks * refactor: pluginize message actions * docs: update provider plugin actions notes * fix: align provider action adapters * refactor: centralize webchat checks * style: format message provider helpers * refactor: move provider onboarding into adapters * docs: note onboarding provider adapters * feat: add msteams onboarding adapter * style: organize onboarding imports * fix: normalize msteams allowFrom types * feat: add plugin text chunk limits * refactor: use plugin chunk limit fallbacks * feat: add provider mention stripping hooks * style: organize provider plugin type imports * refactor: generalize health snapshots * refactor: update macOS health snapshot handling * docs: refresh health snapshot notes * style: format health snapshot updates * refactor: drive security warnings via plugins * docs: note provider security adapter * style: format provider security adapters * refactor: centralize provider account defaults * refactor: type gateway client identity constants * chore: regen gateway protocol swift * fix: degrade health on failed provider probe * refactor: centralize pairing approve hint * docs: add plugin CLI command references * refactor: route auth and tool sends through plugins * docs: expand provider plugin hooks * refactor: document provider docking touchpoints * refactor: normalize internal provider defaults * refactor: streamline outbound delivery wiring * refactor: make provider onboarding plugin-owned * refactor: support provider-owned agent tools * refactor: move telegram draft chunking into telegram module * refactor: infer provider tool sends via extractToolSend * fix: repair plugin onboarding imports * refactor: de-dup outbound target normalization * style: tidy plugin and agent imports * refactor: data-drive provider selection line * fix: satisfy lint after provider plugin rebase * test: deflake gateway-cli coverage * style: format gateway-cli coverage test * refactor(provider-plugins): simplify provider ids * test(pairing-cli): avoid provider-specific ternary * style(macos): swiftformat HealthStore * refactor(sandbox): derive provider tool denylist * fix(sandbox): avoid plugin init in defaults * refactor(provider-plugins): centralize provider aliases * style(test): satisfy biome * refactor(protocol): v3 providers.status maps * refactor(ui): adapt to protocol v3 * refactor(macos): adapt to protocol v3 * test: update providers.status v3 fixtures * refactor(gateway): map provider runtime snapshot * test(gateway): update reload runtime snapshot * refactor(whatsapp): normalize heartbeat provider id * docs(refactor): update provider plugin notes * style: satisfy biome after rebase * fix: describe sandboxed elevated in prompt * feat(gateway): add agent image attachments + live probe * refactor: derive CLI provider options from plugins * fix(gateway): harden agent provider routing * fix(gateway): harden agent provider routing * refactor: align provider ids for schema * fix(protocol): keep agent provider string * fix(gateway): harden agent provider routing * fix(protocol): keep agent provider string * refactor: normalize agent delivery targets * refactor: support provider-owned agent tools * refactor(config): provider-keyed elevated allowFrom * style: satisfy biome * fix(gateway): appease provider narrowing * style: satisfy biome * refactor(reply): move group intro hints into plugin * fix(reply): avoid plugin registry init cycle * refactor(providers): add lightweight provider dock * refactor(gateway): use typed client id in connect * refactor(providers): document docks and avoid init cycles * refactor(providers): make media limit helper generic * fix(providers): break plugin registry import cycles * style: satisfy biome * refactor(status-all): build providers table from plugins * refactor(gateway): delegate web login to provider plugin * refactor(provider): drop web alias * refactor(provider): lazy-load monitors * style: satisfy lint/format * style: format status-all providers table * style: swiftformat gateway discovery model * test: make reload plan plugin-driven * fix: avoid token stringification in status-all * refactor: make provider IDs explicit in status * feat: warn on signal/imessage provider runtime errors * test: cover gateway provider runtime warnings in status * fix: add runtime kind to provider status issues * test: cover health degradation on probe failure * fix: keep routeReply lightweight * style: organize routeReply imports * refactor(web): extract auth-store helpers * refactor(whatsapp): lazy login imports * refactor(outbound): route replies via plugin outbound * docs: update provider plugin notes * style: format provider status issues * fix: make sandbox scope warning wrap-safe * refactor: load outbound adapters from provider plugins * docs: update provider plugin outbound notes * style(macos): fix swiftformat lint * docs: changelog for provider plugins * fix(macos): satisfy swiftformat * fix(macos): open settings via menu action * style: format after rebase * fix(macos): open Settings via menu action --------- Co-authored-by: LK Co-authored-by: Luke K (pr-0f3t) <2609441+lc0rp@users.noreply.github.com> Co-authored-by: Xin --- CHANGELOG.md | 5 +- .../Sources/Clawdbot/CLIInstallPrompter.swift | 6 +- .../macos/Sources/Clawdbot/CLIInstaller.swift | 2 +- .../ConnectionsSettings+ProviderState.swift | 104 +- .../Clawdbot/ConnectionsStore+Lifecycle.swift | 26 +- .../Sources/Clawdbot/ConnectionsStore.swift | 52 +- .../Sources/Clawdbot/GatewayChannel.swift | 8 +- .../Sources/Clawdbot/GatewayConnection.swift | 4 +- .../Clawdbot/GatewayEndpointStore.swift | 10 +- .../Sources/Clawdbot/GatewayEnvironment.swift | 1 - .../Clawdbot/GatewayLaunchAgentManager.swift | 92 +- .../Clawdbot/GatewayProcessManager.swift | 18 +- .../Sources/Clawdbot/GeneralSettings.swift | 20 +- apps/macos/Sources/Clawdbot/HealthStore.swift | 132 +- .../Sources/Clawdbot/InstancesStore.swift | 14 +- .../Sources/Clawdbot/MenuContentView.swift | 7 +- .../Clawdbot/MenuSessionsInjector.swift | 8 +- .../Clawdbot/OnboardingView+Actions.swift | 5 +- .../Clawdbot/OnboardingView+Monitoring.swift | 2 +- .../Sources/Clawdbot/PermissionManager.swift | 5 +- .../Clawdbot/SettingsWindowOpener.swift | 36 + .../GatewayDiscoveryModel.swift | 2 +- .../ClawdbotProtocol/GatewayModels.swift | 52 +- .../ConnectionsSettingsSmokeTests.swift | 242 +-- .../ClawdbotIPCTests/HealthDecodeTests.swift | 6 +- .../HealthStoreStateTests.swift | 46 + docs/cli/index.md | 4 +- docs/concepts/provider-routing.md | 2 +- docs/concepts/typebox.md | 12 +- docs/gateway/health.md | 10 +- docs/gateway/index.md | 4 +- docs/platforms/mac/health.md | 6 +- docs/refactor/provider-plugin.md | 113 ++ docs/tools/slash-commands.md | 4 +- scripts/e2e/gateway-network-docker.sh | 4 +- src/agents/claude-cli-runner.ts | 2 + src/agents/pi-embedded-messaging.ts | 117 +- src/agents/pi-embedded-runner.ts | 2 + src/agents/pi-embedded-subscribe.ts | 67 +- src/agents/pi-tools.ts | 75 +- src/agents/provider-tools.ts | 17 + src/agents/sandbox.ts | 4 +- src/agents/system-prompt.ts | 36 +- src/agents/tool-summaries.ts | 13 + src/agents/tools/gateway.ts | 9 +- src/agents/tools/message-tool.ts | 948 +--------- src/agents/tools/sessions-announce-target.ts | 14 +- src/agents/tools/sessions-send-helpers.ts | 25 +- src/auto-reply/chunk.test.ts | 7 +- src/auto-reply/chunk.ts | 101 +- src/auto-reply/command-auth.ts | 131 +- src/auto-reply/command-detection.test.ts | 8 +- src/auto-reply/commands-registry.ts | 95 +- src/auto-reply/reply.triggers.test.ts | 13 +- src/auto-reply/reply.ts | 81 +- src/auto-reply/reply/agent-runner.ts | 68 +- src/auto-reply/reply/block-streaming.ts | 149 +- src/auto-reply/reply/commands.ts | 51 +- src/auto-reply/reply/followup-runner.ts | 6 +- src/auto-reply/reply/groups.ts | 213 +-- src/auto-reply/reply/mentions.ts | 28 +- src/auto-reply/reply/queue.ts | 9 +- src/auto-reply/reply/reply-threading.ts | 26 +- src/auto-reply/reply/route-reply.test.ts | 13 +- src/auto-reply/reply/route-reply.ts | 172 +- src/auto-reply/reply/session.ts | 10 +- src/auto-reply/templating.ts | 15 +- src/cli/cron-cli.ts | 10 +- src/cli/daemon-cli.ts | 8 +- src/cli/deps.ts | 27 + src/cli/gateway-cli.coverage.test.ts | 6 +- src/cli/gateway-cli.ts | 40 +- src/cli/gateway-rpc.ts | 8 +- src/cli/nodes-cli.ts | 8 +- src/cli/pairing-cli.test.ts | 37 +- src/cli/pairing-cli.ts | 73 +- src/cli/program.ts | 14 +- src/cli/provider-auth.ts | 63 +- src/cli/providers-cli.ts | 4 +- src/commands/agent-via-gateway.ts | 14 +- src/commands/agent.ts | 63 +- src/commands/agents.ts | 211 +-- src/commands/doctor-sandbox.ts | 9 +- src/commands/doctor-security.ts | 170 +- src/commands/gateway-status.test.ts | 20 +- src/commands/health.command.coverage.test.ts | 39 +- src/commands/health.snapshot.test.ts | 69 +- src/commands/health.test.ts | 36 +- src/commands/health.ts | 299 ++- src/commands/message.ts | 761 +------- src/commands/onboard-helpers.ts | 8 +- ...board-non-interactive.gateway-auth.test.ts | 9 +- ...ard-non-interactive.lan-auto-token.test.ts | 9 +- src/commands/onboard-providers.ts | 1651 +---------------- src/commands/onboarding/registry.ts | 29 + src/commands/onboarding/types.ts | 1 + src/commands/providers.test.ts | 184 +- src/commands/providers/add-mutators.ts | 343 +--- src/commands/providers/add.ts | 129 +- src/commands/providers/list.ts | 228 +-- src/commands/providers/logs.ts | 10 +- src/commands/providers/remove.ts | 202 +- src/commands/providers/shared.ts | 14 +- src/commands/providers/status.ts | 199 +- src/commands/sandbox-explain.ts | 76 +- src/commands/status-all.ts | 22 +- src/commands/status-all/providers.ts | 674 ++++--- src/commands/status.test.ts | 124 +- src/commands/status.ts | 157 +- src/config/group-policy.ts | 29 +- src/config/provider-capabilities.ts | 49 +- src/config/sessions.ts | 23 +- src/config/types.ts | 14 +- src/config/zod-schema.ts | 11 +- src/cron/isolated-agent.test.ts | 18 +- src/cron/isolated-agent.ts | 405 ++-- src/cron/types.ts | 14 +- src/discord/monitor.ts | 8 +- src/gateway/call.ts | 16 +- src/gateway/client.ts | 18 +- src/gateway/config-reload.test.ts | 21 +- src/gateway/config-reload.ts | 103 +- .../gateway-models.profiles.live.test.ts | 11 +- .../gateway.tool-calling.mock-openai.test.ts | 9 +- src/gateway/gateway.wizard.e2e.test.ts | 14 +- src/gateway/hooks.ts | 16 +- src/gateway/probe.ts | 8 +- src/gateway/protocol/client-info.ts | 74 + src/gateway/protocol/index.ts | 11 + src/gateway/protocol/schema.ts | 91 +- src/gateway/server-bridge.ts | 10 +- src/gateway/server-methods/agent.ts | 88 +- src/gateway/server-methods/chat.ts | 3 +- src/gateway/server-methods/providers.ts | 755 +++----- src/gateway/server-methods/send.ts | 359 ++-- src/gateway/server-methods/types.ts | 26 +- src/gateway/server-methods/web.ts | 98 +- src/gateway/server-providers.ts | 1453 +++------------ src/gateway/server.agent.test.ts | 21 +- src/gateway/server.chat.test.ts | 8 +- src/gateway/server.health.test.ts | 14 +- src/gateway/server.node-bridge.test.ts | 8 +- src/gateway/server.providers.test.ts | 61 +- src/gateway/server.reload.test.ts | 171 +- src/gateway/server.ts | 222 +-- src/gateway/test-helpers.ts | 11 +- src/infra/heartbeat-runner.ts | 84 +- src/infra/outbound/deliver.ts | 309 ++- src/infra/outbound/format.test.ts | 6 +- src/infra/outbound/format.ts | 9 +- src/infra/outbound/message.ts | 83 +- src/infra/outbound/provider-selection.ts | 88 +- src/infra/outbound/targets.test.ts | 12 + src/infra/outbound/targets.ts | 260 +-- src/infra/provider-activity.ts | 2 +- src/infra/provider-summary.ts | 501 +++-- src/infra/providers-status-issues.ts | 461 +---- src/infra/system-presence.test.ts | 6 +- src/logging.ts | 4 +- src/pairing/pairing-labels.ts | 13 +- src/pairing/pairing-store.ts | 27 +- src/providers/dock.ts | 292 +++ src/providers/google-shared.test.ts | 17 +- src/providers/plugins/actions/discord.ts | 531 ++++++ src/providers/plugins/actions/telegram.ts | 120 ++ .../plugins/agent-tools/whatsapp-login.ts | 74 + src/providers/plugins/config-helpers.ts | 102 + src/providers/plugins/discord.ts | 353 ++++ src/providers/plugins/group-mentions.ts | 196 ++ src/providers/plugins/helpers.ts | 22 + src/providers/plugins/imessage.ts | 288 +++ src/providers/plugins/index.test.ts | 14 + src/providers/plugins/index.ts | 67 + src/providers/plugins/load.ts | 31 + src/providers/plugins/media-limits.ts | 26 + src/providers/plugins/message-actions.ts | 41 + src/providers/plugins/msteams.ts | 200 ++ src/providers/plugins/normalize-target.ts | 119 ++ src/providers/plugins/onboarding-types.ts | 89 + src/providers/plugins/onboarding/discord.ts | 194 ++ src/providers/plugins/onboarding/helpers.ts | 50 + src/providers/plugins/onboarding/imessage.ts | 164 ++ src/providers/plugins/onboarding/msteams.ts | 193 ++ src/providers/plugins/onboarding/signal.ts | 206 ++ src/providers/plugins/onboarding/slack.ts | 309 +++ src/providers/plugins/onboarding/telegram.ts | 262 +++ src/providers/plugins/onboarding/whatsapp.ts | 399 ++++ src/providers/plugins/outbound/discord.ts | 44 + src/providers/plugins/outbound/imessage.ts | 53 + src/providers/plugins/outbound/load.ts | 32 + src/providers/plugins/outbound/msteams.ts | 60 + src/providers/plugins/outbound/signal.ts | 51 + src/providers/plugins/outbound/slack.ts | 37 + src/providers/plugins/outbound/telegram.ts | 56 + src/providers/plugins/outbound/whatsapp.ts | 94 + src/providers/plugins/pairing-message.ts | 2 + src/providers/plugins/pairing.ts | 68 + src/providers/plugins/setup-helpers.ts | 116 ++ src/providers/plugins/signal.ts | 314 ++++ src/providers/plugins/slack.ts | 505 +++++ .../plugins/status-issues/discord.ts | 145 ++ src/providers/plugins/status-issues/shared.ts | 9 + .../plugins/status-issues/telegram.ts | 123 ++ .../plugins/status-issues/whatsapp.ts | 70 + src/providers/plugins/status.ts | 39 + src/providers/plugins/telegram.ts | 465 +++++ src/providers/plugins/types.ts | 580 ++++++ src/providers/plugins/whatsapp-heartbeat.ts | 77 + src/providers/plugins/whatsapp.ts | 476 +++++ src/providers/registry.test.ts | 1 + src/providers/registry.ts | 63 +- src/telegram/bot.media.test.ts | 13 + src/telegram/bot.ts | 2 +- .../draft-chunking.test.ts} | 2 +- src/telegram/draft-chunking.ts | 43 + src/tui/gateway-chat.ts | 9 +- src/tui/tui.ts | 23 +- src/utils/message-provider.test.ts | 1 + src/utils/message-provider.ts | 90 +- src/web/auth-store.ts | 188 ++ src/web/auto-reply.ts | 63 +- src/web/login.coverage.test.ts | 14 +- src/web/login.test.ts | 2 +- src/web/login.ts | 10 +- src/web/session.ts | 194 +- src/wizard/onboarding.ts | 10 +- ui/src/ui/controllers/connections.ts | 16 +- ui/src/ui/gateway.ts | 18 +- ui/src/ui/types.ts | 63 +- ui/src/ui/ui-types.ts | 3 +- ui/src/ui/views/connections.ts | 64 +- ui/src/ui/views/cron.ts | 1 + 232 files changed, 13642 insertions(+), 10809 deletions(-) create mode 100644 apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift create mode 100644 apps/macos/Tests/ClawdbotIPCTests/HealthStoreStateTests.swift create mode 100644 docs/refactor/provider-plugin.md create mode 100644 src/agents/provider-tools.ts create mode 100644 src/agents/tool-summaries.ts create mode 100644 src/commands/onboarding/registry.ts create mode 100644 src/commands/onboarding/types.ts create mode 100644 src/gateway/protocol/client-info.ts create mode 100644 src/providers/dock.ts create mode 100644 src/providers/plugins/actions/discord.ts create mode 100644 src/providers/plugins/actions/telegram.ts create mode 100644 src/providers/plugins/agent-tools/whatsapp-login.ts create mode 100644 src/providers/plugins/config-helpers.ts create mode 100644 src/providers/plugins/discord.ts create mode 100644 src/providers/plugins/group-mentions.ts create mode 100644 src/providers/plugins/helpers.ts create mode 100644 src/providers/plugins/imessage.ts create mode 100644 src/providers/plugins/index.test.ts create mode 100644 src/providers/plugins/index.ts create mode 100644 src/providers/plugins/load.ts create mode 100644 src/providers/plugins/media-limits.ts create mode 100644 src/providers/plugins/message-actions.ts create mode 100644 src/providers/plugins/msteams.ts create mode 100644 src/providers/plugins/normalize-target.ts create mode 100644 src/providers/plugins/onboarding-types.ts create mode 100644 src/providers/plugins/onboarding/discord.ts create mode 100644 src/providers/plugins/onboarding/helpers.ts create mode 100644 src/providers/plugins/onboarding/imessage.ts create mode 100644 src/providers/plugins/onboarding/msteams.ts create mode 100644 src/providers/plugins/onboarding/signal.ts create mode 100644 src/providers/plugins/onboarding/slack.ts create mode 100644 src/providers/plugins/onboarding/telegram.ts create mode 100644 src/providers/plugins/onboarding/whatsapp.ts create mode 100644 src/providers/plugins/outbound/discord.ts create mode 100644 src/providers/plugins/outbound/imessage.ts create mode 100644 src/providers/plugins/outbound/load.ts create mode 100644 src/providers/plugins/outbound/msteams.ts create mode 100644 src/providers/plugins/outbound/signal.ts create mode 100644 src/providers/plugins/outbound/slack.ts create mode 100644 src/providers/plugins/outbound/telegram.ts create mode 100644 src/providers/plugins/outbound/whatsapp.ts create mode 100644 src/providers/plugins/pairing-message.ts create mode 100644 src/providers/plugins/pairing.ts create mode 100644 src/providers/plugins/setup-helpers.ts create mode 100644 src/providers/plugins/signal.ts create mode 100644 src/providers/plugins/slack.ts create mode 100644 src/providers/plugins/status-issues/discord.ts create mode 100644 src/providers/plugins/status-issues/shared.ts create mode 100644 src/providers/plugins/status-issues/telegram.ts create mode 100644 src/providers/plugins/status-issues/whatsapp.ts create mode 100644 src/providers/plugins/status.ts create mode 100644 src/providers/plugins/telegram.ts create mode 100644 src/providers/plugins/types.ts create mode 100644 src/providers/plugins/whatsapp-heartbeat.ts create mode 100644 src/providers/plugins/whatsapp.ts rename src/{auto-reply/reply/block-streaming.test.ts => telegram/draft-chunking.test.ts} (94%) create mode 100644 src/telegram/draft-chunking.ts create mode 100644 src/web/auth-store.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a97ae7f7a..b23573f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ - CLI: `clawdbot status` now table-based + shows OS/update/gateway/daemon/agents/sessions; `status --all` adds a full read-only debug report (tables, log tails, Tailscale summary, and scan progress via OSC-9 + spinner). - CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe. - CLI: add `clawdbot update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa. -- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680) — thanks @steipete. +- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680). ### Changes - Onboarding/Models: add first-class Z.AI (GLM) auth choice (`zai-api-key`) + `--zai-api-key` flag. @@ -30,7 +30,8 @@ - Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik. - Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal. - CLI: add `clawdbot reset` and `clawdbot uninstall` flows (interactive + non-interactive) plus docker cleanup smoke test. -- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672) — thanks @steipete. +- Providers: move provider wiring to a plugin architecture. (#661). +- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672). - Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690) - Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo. diff --git a/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift b/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift index 5f6afe067..0e7e43c40 100644 --- a/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift +++ b/apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift @@ -61,8 +61,10 @@ final class CLIInstallPrompter { private func openSettings(tab: SettingsTab) { SettingsTabRouter.request(tab) - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) - NSApp.sendAction(#selector(NSApplication.showSettingsWindow), to: nil, from: nil) + SettingsWindowOpener.shared.open() + DispatchQueue.main.async { + NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + } } private static func appVersion() -> String? { diff --git a/apps/macos/Sources/Clawdbot/CLIInstaller.swift b/apps/macos/Sources/Clawdbot/CLIInstaller.swift index 546e8c053..41abb29e6 100644 --- a/apps/macos/Sources/Clawdbot/CLIInstaller.swift +++ b/apps/macos/Sources/Clawdbot/CLIInstaller.swift @@ -34,7 +34,7 @@ enum CLIInstaller { self.installedLocation() != nil } - static func install(statusHandler: @escaping @Sendable (String) async -> Void) async { + static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async { let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest" let prefix = Self.installPrefix() await statusHandler("Installing clawdbot CLI…") diff --git a/apps/macos/Sources/Clawdbot/ConnectionsSettings+ProviderState.swift b/apps/macos/Sources/Clawdbot/ConnectionsSettings+ProviderState.swift index ab0b92577..ee7439c63 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsSettings+ProviderState.swift +++ b/apps/macos/Sources/Clawdbot/ConnectionsSettings+ProviderState.swift @@ -1,8 +1,16 @@ import SwiftUI extension ConnectionsSettings { + private func providerStatus( + _ id: String, + as type: T.Type) -> T? + { + self.store.snapshot?.decodeProvider(id, as: type) + } + var whatsAppTint: Color { - guard let status = self.store.snapshot?.whatsapp else { return .secondary } + guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) + else { return .secondary } if !status.configured { return .secondary } if !status.linked { return .red } if status.lastError != nil { return .orange } @@ -12,7 +20,8 @@ extension ConnectionsSettings { } var telegramTint: Color { - guard let status = self.store.snapshot?.telegram else { return .secondary } + guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) + else { return .secondary } if !status.configured { return .secondary } if status.lastError != nil { return .orange } if status.probe?.ok == false { return .orange } @@ -21,7 +30,8 @@ extension ConnectionsSettings { } var discordTint: Color { - guard let status = self.store.snapshot?.discord else { return .secondary } + guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) + else { return .secondary } if !status.configured { return .secondary } if status.lastError != nil { return .orange } if status.probe?.ok == false { return .orange } @@ -30,7 +40,8 @@ extension ConnectionsSettings { } var signalTint: Color { - guard let status = self.store.snapshot?.signal else { return .secondary } + guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) + else { return .secondary } if !status.configured { return .secondary } if status.lastError != nil { return .orange } if status.probe?.ok == false { return .orange } @@ -39,7 +50,8 @@ extension ConnectionsSettings { } var imessageTint: Color { - guard let status = self.store.snapshot?.imessage else { return .secondary } + guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) + else { return .secondary } if !status.configured { return .secondary } if status.lastError != nil { return .orange } if status.probe?.ok == false { return .orange } @@ -48,7 +60,8 @@ extension ConnectionsSettings { } var whatsAppSummary: String { - guard let status = self.store.snapshot?.whatsapp else { return "Checking…" } + guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) + else { return "Checking…" } if !status.linked { return "Not linked" } if status.connected { return "Connected" } if status.running { return "Running" } @@ -56,35 +69,40 @@ extension ConnectionsSettings { } var telegramSummary: String { - guard let status = self.store.snapshot?.telegram else { return "Checking…" } + guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) + else { return "Checking…" } if !status.configured { return "Not configured" } if status.running { return "Running" } return "Configured" } var discordSummary: String { - guard let status = self.store.snapshot?.discord else { return "Checking…" } + guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) + else { return "Checking…" } if !status.configured { return "Not configured" } if status.running { return "Running" } return "Configured" } var signalSummary: String { - guard let status = self.store.snapshot?.signal else { return "Checking…" } + guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) + else { return "Checking…" } if !status.configured { return "Not configured" } if status.running { return "Running" } return "Configured" } var imessageSummary: String { - guard let status = self.store.snapshot?.imessage else { return "Checking…" } + guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) + else { return "Checking…" } if !status.configured { return "Not configured" } if status.running { return "Running" } return "Configured" } var whatsAppDetails: String? { - guard let status = self.store.snapshot?.whatsapp else { return nil } + guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) + else { return nil } var lines: [String] = [] if let e164 = status.`self`?.e164 ?? status.`self`?.jid { lines.append("Linked as \(e164)") @@ -114,7 +132,8 @@ extension ConnectionsSettings { } var telegramDetails: String? { - guard let status = self.store.snapshot?.telegram else { return nil } + guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) + else { return nil } var lines: [String] = [] if let source = status.tokenSource { lines.append("Token source: \(source)") @@ -145,7 +164,8 @@ extension ConnectionsSettings { } var discordDetails: String? { - guard let status = self.store.snapshot?.discord else { return nil } + guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) + else { return nil } var lines: [String] = [] if let source = status.tokenSource { lines.append("Token source: \(source)") @@ -173,7 +193,8 @@ extension ConnectionsSettings { } var signalDetails: String? { - guard let status = self.store.snapshot?.signal else { return nil } + guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) + else { return nil } var lines: [String] = [] lines.append("Base URL: \(status.baseUrl)") if let probe = status.probe { @@ -199,7 +220,8 @@ extension ConnectionsSettings { } var imessageDetails: String? { - guard let status = self.store.snapshot?.imessage else { return nil } + guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) + else { return nil } var lines: [String] = [] if let cliPath = status.cliPath, !cliPath.isEmpty { lines.append("CLI: \(cliPath)") @@ -221,11 +243,11 @@ extension ConnectionsSettings { } var isTelegramTokenLocked: Bool { - self.store.snapshot?.telegram.tokenSource == "env" + self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?.tokenSource == "env" } var isDiscordTokenLocked: Bool { - self.store.snapshot?.discord?.tokenSource == "env" + self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?.tokenSource == "env" } var orderedProviders: [ConnectionProvider] { @@ -258,19 +280,24 @@ extension ConnectionsSettings { func providerEnabled(_ provider: ConnectionProvider) -> Bool { switch provider { case .whatsapp: - guard let status = self.store.snapshot?.whatsapp else { return false } + guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) + else { return false } return status.configured || status.linked || status.running case .telegram: - guard let status = self.store.snapshot?.telegram else { return false } + guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) + else { return false } return status.configured || status.running case .discord: - guard let status = self.store.snapshot?.discord else { return false } + guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) + else { return false } return status.configured || status.running case .signal: - guard let status = self.store.snapshot?.signal else { return false } + guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) + else { return false } return status.configured || status.running case .imessage: - guard let status = self.store.snapshot?.imessage else { return false } + guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) + else { return false } return status.configured || status.running } } @@ -344,35 +371,48 @@ extension ConnectionsSettings { func providerLastCheck(_ provider: ConnectionProvider) -> Date? { switch provider { case .whatsapp: - guard let status = self.store.snapshot?.whatsapp else { return nil } + guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) + else { return nil } return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt) case .telegram: - return self.date(fromMs: self.store.snapshot?.telegram.lastProbeAt) + return self + .date(fromMs: self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)? + .lastProbeAt) case .discord: - return self.date(fromMs: self.store.snapshot?.discord?.lastProbeAt) + return self + .date(fromMs: self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)? + .lastProbeAt) case .signal: - return self.date(fromMs: self.store.snapshot?.signal?.lastProbeAt) + return self + .date(fromMs: self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)?.lastProbeAt) case .imessage: - return self.date(fromMs: self.store.snapshot?.imessage?.lastProbeAt) + return self + .date(fromMs: self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)? + .lastProbeAt) } } func providerHasError(_ provider: ConnectionProvider) -> Bool { switch provider { case .whatsapp: - guard let status = self.store.snapshot?.whatsapp else { return false } + guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self) + else { return false } return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true case .telegram: - guard let status = self.store.snapshot?.telegram else { return false } + guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self) + else { return false } return status.lastError?.isEmpty == false || status.probe?.ok == false case .discord: - guard let status = self.store.snapshot?.discord else { return false } + guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self) + else { return false } return status.lastError?.isEmpty == false || status.probe?.ok == false case .signal: - guard let status = self.store.snapshot?.signal else { return false } + guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self) + else { return false } return status.lastError?.isEmpty == false || status.probe?.ok == false case .imessage: - guard let status = self.store.snapshot?.imessage else { return false } + guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self) + else { return false } return status.lastError?.isEmpty == false || status.probe?.ok == false } } diff --git a/apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift b/apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift index 7227330aa..58d372c50 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift +++ b/apps/macos/Sources/Clawdbot/ConnectionsStore+Lifecycle.swift @@ -100,9 +100,12 @@ extension ConnectionsStore { self.whatsappBusy = true defer { self.whatsappBusy = false } do { - let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded( - method: .webLogout, - params: nil, + let params: [String: AnyCodable] = [ + "provider": AnyCodable("whatsapp"), + ] + let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded( + method: .providersLogout, + params: params, timeoutMs: 15000) self.whatsappLoginMessage = result.cleared ? "Logged out and cleared credentials." @@ -119,9 +122,12 @@ extension ConnectionsStore { self.telegramBusy = true defer { self.telegramBusy = false } do { - let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded( - method: .telegramLogout, - params: nil, + let params: [String: AnyCodable] = [ + "provider": AnyCodable("telegram"), + ] + let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded( + method: .providersLogout, + params: params, timeoutMs: 15000) if result.envToken == true { self.configStatus = "Telegram token still set via env; config cleared." @@ -148,11 +154,9 @@ private struct WhatsAppLoginWaitResult: Codable { let message: String } -private struct WhatsAppLogoutResult: Codable { - let cleared: Bool -} - -private struct TelegramLogoutResult: Codable { +private struct ProviderLogoutResult: Codable { + let provider: String? + let accountId: String? let cleared: Bool let envToken: Bool? } diff --git a/apps/macos/Sources/Clawdbot/ConnectionsStore.swift b/apps/macos/Sources/Clawdbot/ConnectionsStore.swift index 455036356..1302f1532 100644 --- a/apps/macos/Sources/Clawdbot/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdbot/ConnectionsStore.swift @@ -121,12 +121,54 @@ struct ProvidersStatusSnapshot: Codable { let lastProbeAt: Double? } + struct ProviderAccountSnapshot: Codable { + let accountId: String + let name: String? + let enabled: Bool? + let configured: Bool? + let linked: Bool? + let running: Bool? + let connected: Bool? + let reconnectAttempts: Int? + let lastConnectedAt: Double? + let lastError: String? + let lastStartAt: Double? + let lastStopAt: Double? + let lastInboundAt: Double? + let lastOutboundAt: Double? + let lastProbeAt: Double? + let mode: String? + let dmPolicy: String? + let allowFrom: [String]? + let tokenSource: String? + let botTokenSource: String? + let appTokenSource: String? + let baseUrl: String? + let allowUnmentionedGroups: Bool? + let cliPath: String? + let dbPath: String? + let port: Int? + let probe: AnyCodable? + let audit: AnyCodable? + let application: AnyCodable? + } + let ts: Double - let whatsapp: WhatsAppStatus - let telegram: TelegramStatus - let discord: DiscordStatus? - let signal: SignalStatus? - let imessage: IMessageStatus? + let providerOrder: [String] + let providerLabels: [String: String] + let providers: [String: AnyCodable] + let providerAccounts: [String: [ProviderAccountSnapshot]] + let providerDefaultAccountId: [String: String] + + func decodeProvider(_ id: String, as type: T.Type) -> T? { + guard let value = self.providers[id] else { return nil } + do { + let data = try JSONEncoder().encode(value) + return try JSONDecoder().decode(type, from: data) + } catch { + return nil + } + } } struct ConfigSnapshot: Codable { diff --git a/apps/macos/Sources/Clawdbot/GatewayChannel.swift b/apps/macos/Sources/Clawdbot/GatewayChannel.swift index f7245e196..d1f175e04 100644 --- a/apps/macos/Sources/Clawdbot/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdbot/GatewayChannel.swift @@ -192,15 +192,17 @@ actor GatewayChannelActor { let osVersion = ProcessInfo.processInfo.operatingSystemVersion let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier - let clientName = InstanceIdentity.displayName + let clientDisplayName = InstanceIdentity.displayName + let clientId = "clawdbot-macos" let reqId = UUID().uuidString var client: [String: ProtoAnyCodable] = [ - "name": ProtoAnyCodable(clientName), + "id": ProtoAnyCodable(clientId), + "displayName": ProtoAnyCodable(clientDisplayName), "version": ProtoAnyCodable( Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), "platform": ProtoAnyCodable(platform), - "mode": ProtoAnyCodable("app"), + "mode": ProtoAnyCodable("ui"), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), ] client["deviceFamily"] = ProtoAnyCodable("Mac") diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index fb1f0a7d6..00699f651 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -13,6 +13,7 @@ enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable { case slack case signal case imessage + case msteams case webchat init(raw: String?) { @@ -61,8 +62,7 @@ actor GatewayConnection { case talkMode = "talk.mode" case webLoginStart = "web.login.start" case webLoginWait = "web.login.wait" - case webLogout = "web.logout" - case telegramLogout = "telegram.logout" + case providersLogout = "providers.logout" case modelsList = "models.list" case chatHistory = "chat.history" case chatSend = "chat.send" diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index 8acece101..cf7e9e6e1 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -226,7 +226,10 @@ actor GatewayEndpointStore { } let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail) guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else { - throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"]) + throw NSError( + domain: "GatewayEndpoint", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"]) } return port } @@ -290,7 +293,10 @@ actor GatewayEndpointStore { let forwarded = try await ensure.task.value let stillRemote = await self.deps.mode() == .remote guard stillRemote else { - throw NSError(domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) + throw NSError( + domain: "RemoteTunnel", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) } if self.remoteEnsure?.token == ensure.token { diff --git a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift index 0e04b13af..d1371cdf1 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift @@ -319,5 +319,4 @@ enum GatewayEnvironment { else { return nil } return Semver.parse(version) } - } diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift index 27bb221dc..7b451541b 100644 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift @@ -6,6 +6,17 @@ enum GatewayLaunchAgentManager { private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway" private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" + private enum GatewayProgramArgumentsError: LocalizedError { + case cliNotFound + + var errorDescription: String? { + switch self { + case .cliNotFound: + "clawdbot CLI not found in PATH; install the CLI." + } + } + } + private static var plistURL: URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") @@ -16,21 +27,27 @@ enum GatewayLaunchAgentManager { .appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist") } - private static func gatewayProgramArguments(port: Int, bind: String) -> Result<[String], String> { + private static func gatewayProgramArguments( + port: Int, + bind: String) -> Result<[String], GatewayProgramArgumentsError> + { #if DEBUG let projectRoot = CommandResolver.projectRoot() if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) { return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]) } - if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot), - case let .success(runtime) = CommandResolver.runtimeResolution() - { - let cmd = CommandResolver.makeRuntimeCommand( - runtime: runtime, - entrypoint: entry, - subcommand: "gateway-daemon", - extraArgs: ["--port", "\(port)", "--bind", bind]) - return .success(cmd) + if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) { + switch CommandResolver.runtimeResolution() { + case let .success(runtime): + let cmd = CommandResolver.makeRuntimeCommand( + runtime: runtime, + entrypoint: entry, + subcommand: "gateway-daemon", + extraArgs: ["--port", "\(port)", "--bind", bind]) + return .success(cmd) + case .failure: + break + } } #endif let searchPaths = CommandResolver.preferredPaths() @@ -38,19 +55,22 @@ enum GatewayLaunchAgentManager { return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]) } - let projectRoot = CommandResolver.projectRoot() - if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot), - case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths) - { - let cmd = CommandResolver.makeRuntimeCommand( - runtime: runtime, - entrypoint: entry, - subcommand: "gateway-daemon", - extraArgs: ["--port", "\(port)", "--bind", bind]) - return .success(cmd) + let fallbackProjectRoot = CommandResolver.projectRoot() + if let entry = CommandResolver.gatewayEntrypoint(in: fallbackProjectRoot) { + switch CommandResolver.runtimeResolution(searchPaths: searchPaths) { + case let .success(runtime): + let cmd = CommandResolver.makeRuntimeCommand( + runtime: runtime, + entrypoint: entry, + subcommand: "gateway-daemon", + extraArgs: ["--port", "\(port)", "--bind", bind]) + return .success(cmd) + case .failure: + break + } } - return .failure("clawdbot CLI not found in PATH; install the CLI.") + return .failure(.cliNotFound) } static func isLoaded() async -> Bool { @@ -78,25 +98,26 @@ enum GatewayLaunchAgentManager { token: desiredToken, password: desiredPassword) let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind) - guard case let .success(programArguments) = programArgumentsResult else { - if case let .failure(message) = programArgumentsResult { - self.logger.error("launchd enable failed: \(message)") - return message - } - return "Failed to resolve gateway command." + let programArguments: [String] + switch programArgumentsResult { + case let .success(args): + programArguments = args + case let .failure(error): + let message = error.errorDescription ?? "Failed to resolve gateway CLI" + self.logger.error("launchd enable failed: \(message)") + return message } // If launchd already loaded the job (common on login), avoid `bootout` unless we must // change the config. `bootout` can kill a just-started gateway and cause attach loops. let loaded = await self.isLoaded() - if loaded, - let existing = self.readPlistConfig(), - existing.matches(desiredConfig) - { - self.logger.info("launchd job already loaded with desired config; skipping bootout") - await self.ensureEnabled() - _ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) - return nil + if loaded { + if let existing = self.readPlistConfig(), existing.matches(desiredConfig) { + self.logger.info("launchd job already loaded with desired config; skipping bootout") + await self.ensureEnabled() + _ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) + return nil + } } self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)") @@ -129,7 +150,6 @@ enum GatewayLaunchAgentManager { _ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) } - private static func writePlist(bundlePath: String, port: Int) { private static func writePlist(programArguments: [String]) { let preferredPath = CommandResolver.preferredPaths().joined(separator: ":") let token = self.preferredGatewayToken() diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift index afb7802c0..921033ba8 100644 --- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift @@ -221,9 +221,21 @@ final class GatewayProcessManager { private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String { let instanceText = instance ?? "pid unknown" if let snap { - let linked = snap.web.linked ? "linked" : "not linked" - let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age" - return "port \(port), \(linked), auth \(authAge), \(instanceText)" + let linkId = snap.providerOrder?.first(where: { + if let summary = snap.providers[$0] { return summary.linked != nil } + return false + }) ?? snap.providers.keys.first(where: { + if let summary = snap.providers[$0] { return summary.linked != nil } + return false + }) + let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false + let authAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age" + let label = + linkId.flatMap { snap.providerLabels?[$0] } ?? + linkId?.capitalized ?? + "provider" + let linkText = linked ? "linked" : "not linked" + return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)" } return "port \(port), health probe succeeded, \(instanceText)" } diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 084f5f591..2ed77ee88 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -461,10 +461,8 @@ struct GeneralSettings: View { self.isInstallingCLI = true defer { isInstallingCLI = false } await CLIInstaller.install { status in - await MainActor.run { - self.cliStatus = status - self.refreshCLIStatus() - } + self.cliStatus = status + self.refreshCLIStatus() } } @@ -503,7 +501,19 @@ struct GeneralSettings: View { } if let snap = snapshot { - Text("Linked auth age: \(healthAgeString(snap.web.authAgeMs))") + let linkId = snap.providerOrder?.first(where: { + if let summary = snap.providers[$0] { return summary.linked != nil } + return false + }) ?? snap.providers.keys.first(where: { + if let summary = snap.providers[$0] { return summary.linked != nil } + return false + }) + let linkLabel = + linkId.flatMap { snap.providerLabels?[$0] } ?? + linkId?.capitalized ?? + "Link provider" + let linkAge = linkId.flatMap { snap.providers[$0]?.authAgeMs } + Text("\(linkLabel) auth age: \(healthAgeString(linkAge))") .font(.caption) .foregroundStyle(.secondary) Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)") diff --git a/apps/macos/Sources/Clawdbot/HealthStore.swift b/apps/macos/Sources/Clawdbot/HealthStore.swift index c2974be8a..ef5d01e82 100644 --- a/apps/macos/Sources/Clawdbot/HealthStore.swift +++ b/apps/macos/Sources/Clawdbot/HealthStore.swift @@ -4,35 +4,29 @@ import Observation import SwiftUI struct HealthSnapshot: Codable, Sendable { - struct Telegram: Codable, Sendable { + struct ProviderSummary: Codable, Sendable { struct Probe: Codable, Sendable { struct Bot: Codable, Sendable { - let id: Int? let username: String? } - let ok: Bool + struct Webhook: Codable, Sendable { + let url: String? + } + + let ok: Bool? let status: Int? let error: String? let elapsedMs: Double? let bot: Bot? + let webhook: Webhook? } - let configured: Bool - let probe: Probe? - } - - struct Web: Codable, Sendable { - struct Connect: Codable, Sendable { - let ok: Bool - let status: Int? - let error: String? - let elapsedMs: Double? - } - - let linked: Bool + let configured: Bool? + let linked: Bool? let authAgeMs: Double? - let connect: Connect? + let probe: Probe? + let lastProbeAt: Double? } struct SessionInfo: Codable, Sendable { @@ -50,8 +44,9 @@ struct HealthSnapshot: Codable, Sendable { let ok: Bool? let ts: Double let durationMs: Double - let web: Web - let telegram: Telegram? + let providers: [String: ProviderSummary] + let providerOrder: [String]? + let providerLabels: [String: String]? let heartbeatSeconds: Int? let sessions: Sessions } @@ -94,6 +89,13 @@ final class HealthStore { } } + // Test-only escape hatch: the HealthStore is a process-wide singleton but + // state derivation is pure from `snapshot` + `lastError`. + func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) { + self.snapshot = snapshot + self.lastError = lastError + } + func start() { guard self.loopTask == nil else { return } self.loopTask = Task { [weak self] in @@ -142,10 +144,49 @@ final class HealthStore { } } - private static func isTelegramHealthy(_ snap: HealthSnapshot) -> Bool { - guard let tg = snap.telegram, tg.configured else { return false } + private static func isProviderHealthy(_ summary: HealthSnapshot.ProviderSummary) -> Bool { + guard summary.configured == true else { return false } // If probe is missing, treat it as "configured but unknown health" (not a hard fail). - return tg.probe?.ok ?? true + return summary.probe?.ok ?? true + } + + private static func describeProbeFailure(_ probe: HealthSnapshot.ProviderSummary.Probe) -> String { + let elapsed = probe.elapsedMs.map { "\(Int($0))ms" } + if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil { + if let elapsed { return "Health check timed out (\(elapsed))" } + return "Health check timed out" + } + let code = probe.status.map { "status \($0)" } ?? "status unknown" + let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed" + if let elapsed { return "\(reason) (\(code), \(elapsed))" } + return "\(reason) (\(code))" + } + + private func resolveLinkProvider( + _ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ProviderSummary)? + { + let order = snap.providerOrder ?? Array(snap.providers.keys) + for id in order { + if let summary = snap.providers[id], summary.linked != nil { + return (id: id, summary: summary) + } + } + return nil + } + + private func resolveFallbackProvider( + _ snap: HealthSnapshot, + excluding id: String?) -> (id: String, summary: HealthSnapshot.ProviderSummary)? + { + let order = snap.providerOrder ?? Array(snap.providers.keys) + for providerId in order { + if providerId == id { continue } + guard let summary = snap.providers[providerId] else { continue } + if Self.isProviderHealthy(summary) { + return (id: providerId, summary: summary) + } + } + return nil } var state: HealthState { @@ -153,13 +194,15 @@ final class HealthStore { return .degraded(error) } guard let snap = self.snapshot else { return .unknown } - if !snap.web.linked { - // WhatsApp Web linking is optional if Telegram is healthy; don't paint the whole app red. - return Self.isTelegramHealthy(snap) ? .degraded("Not linked") : .linkingNeeded + guard let link = self.resolveLinkProvider(snap) else { return .unknown } + if link.summary.linked != true { + // Linking is optional if any other provider is healthy; don't paint the whole app red. + let fallback = self.resolveFallbackProvider(snap, excluding: link.id) + return fallback != nil ? .degraded("Not linked") : .linkingNeeded } - if let connect = snap.web.connect, !connect.ok { - let reason = connect.error ?? "connect failed" - return .degraded(reason) + // A provider can be "linked" but still unhealthy (failed probe / cannot connect). + if let probe = link.summary.probe, probe.ok == false { + return .degraded(Self.describeProbeFailure(probe)) } return .ok } @@ -168,19 +211,22 @@ final class HealthStore { if self.isRefreshing { return "Health check running…" } if let error = self.lastError { return "Health check failed: \(error)" } guard let snap = self.snapshot else { return "Health check pending" } - if !snap.web.linked { - if let tg = snap.telegram, tg.configured { - let tgLabel = (tg.probe?.ok ?? true) ? "Telegram ok" : "Telegram degraded" - return "\(tgLabel) · Not linked — run clawdbot login" + guard let link = self.resolveLinkProvider(snap) else { return "Health check pending" } + if link.summary.linked != true { + if let fallback = self.resolveFallbackProvider(snap, excluding: link.id) { + let fallbackLabel = snap.providerLabels?[fallback.id] ?? fallback.id.capitalized + let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded" + return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login" } return "Not linked — run clawdbot login" } - let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown" - if let connect = snap.web.connect, !connect.ok { - let code = connect.status.map(String.init) ?? "?" - return "Link stale? status \(code)" + let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown" + if let probe = link.summary.probe, probe.ok == false { + let status = probe.status.map(String.init) ?? "?" + let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)" + return "linked · auth \(auth) · \(suffix)" } - return "linked · auth \(auth) · socket ok" + return "linked · auth \(auth)" } /// Short, human-friendly detail for the last failure, used in the UI. @@ -201,17 +247,11 @@ final class HealthStore { } func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String { - if !snap.web.linked { + if let link = self.resolveLinkProvider(snap), link.summary.linked != true { return "Not linked — run clawdbot login" } - if let connect = snap.web.connect, !connect.ok { - let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration" - if let err = connect.error, err.lowercased().contains("timeout") || connect.status == nil { - return "Health check timed out (\(elapsed))" - } - let code = connect.status.map { "status \($0)" } ?? "status unknown" - let reason = connect.error ?? "connect failed" - return "\(reason) (\(code), \(elapsed))" + if let link = self.resolveLinkProvider(snap), let probe = link.summary.probe, probe.ok == false { + return Self.describeProbeFailure(probe) } if let fallback, !fallback.isEmpty { return fallback diff --git a/apps/macos/Sources/Clawdbot/InstancesStore.swift b/apps/macos/Sources/Clawdbot/InstancesStore.swift index 25cd76b8f..d2a0e6ce0 100644 --- a/apps/macos/Sources/Clawdbot/InstancesStore.swift +++ b/apps/macos/Sources/Clawdbot/InstancesStore.swift @@ -242,6 +242,18 @@ final class InstancesStore { do { let data = try await ControlChannel.shared.health(timeout: 8) guard let snap = decodeHealthSnapshot(from: data) else { return } + let linkId = snap.providerOrder?.first(where: { + if let summary = snap.providers[$0] { return summary.linked != nil } + return false + }) ?? snap.providers.keys.first(where: { + if let summary = snap.providers[$0] { return summary.linked != nil } + return false + }) + let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false + let linkLabel = + linkId.flatMap { snap.providerLabels?[$0] } ?? + linkId?.capitalized ?? + "provider" let entry = InstanceInfo( id: "health-\(snap.ts)", host: "gateway (health)", @@ -253,7 +265,7 @@ final class InstancesStore { lastInputSeconds: nil, mode: "health", reason: "health probe", - text: "Health ok · linked=\(snap.web.linked)", + text: "Health ok · \(linkLabel) linked=\(linked)", ts: snap.ts) if !self.instances.contains(where: { $0.id == entry.id }) { self.instances.insert(entry, at: 0) diff --git a/apps/macos/Sources/Clawdbot/MenuContentView.swift b/apps/macos/Sources/Clawdbot/MenuContentView.swift index e16e114e6..cd4ae26c9 100644 --- a/apps/macos/Sources/Clawdbot/MenuContentView.swift +++ b/apps/macos/Sources/Clawdbot/MenuContentView.swift @@ -153,6 +153,9 @@ struct MenuContent: View { self.micRefreshTask = nil self.micObserver.stop() } + .task { @MainActor in + SettingsWindowOpener.shared.register(openSettings: self.openSettings) + } } private var connectionLabel: String { @@ -301,7 +304,9 @@ struct MenuContent: View { SettingsTabRouter.request(tab) NSApp.activate(ignoringOtherApps: true) self.openSettings() - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + } } @MainActor diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index 0d9cc2e2f..732d0114b 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -395,13 +395,13 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String { switch state { case .connected: - return "Loading sessions…" + "Loading sessions…" case .connecting: - return "Connecting…" + "Connecting…" case let .degraded(message): - return message.nonEmpty ?? "Gateway disconnected" + message.nonEmpty ?? "Gateway disconnected" case .disconnected: - return "Gateway disconnected" + "Gateway disconnected" } } diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift index 12dee200e..b84ca5e8d 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift @@ -1,6 +1,7 @@ import AppKit import ClawdbotDiscovery import ClawdbotIPC +import Foundation import SwiftUI extension OnboardingView { @@ -41,7 +42,9 @@ extension OnboardingView { func openSettings(tab: SettingsTab) { SettingsTabRouter.request(tab) self.openSettings() - NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab) + } } func handleBack() { diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Monitoring.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Monitoring.swift index 751f8b388..27e4e881a 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Monitoring.swift @@ -95,7 +95,7 @@ extension OnboardingView { self.installingCLI = true defer { installingCLI = false } await CLIInstaller.install { message in - await MainActor.run { self.cliStatus = message } + self.cliStatus = message } self.refreshCLIStatus() } diff --git a/apps/macos/Sources/Clawdbot/PermissionManager.swift b/apps/macos/Sources/Clawdbot/PermissionManager.swift index fbd2f766a..21a88b07e 100644 --- a/apps/macos/Sources/Clawdbot/PermissionManager.swift +++ b/apps/macos/Sources/Clawdbot/PermissionManager.swift @@ -345,7 +345,10 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { } // Legacy callback (still used on some macOS versions / configurations). - nonisolated func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + nonisolated func locationManager( + _ manager: CLLocationManager, + didChangeAuthorization status: CLAuthorizationStatus) + { Task { @MainActor in self.finish(status: status) } diff --git a/apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift b/apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift new file mode 100644 index 000000000..9cc1647b6 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift @@ -0,0 +1,36 @@ +import AppKit +import SwiftUI + +@objc +private protocol SettingsWindowMenuActions { + @objc(showSettingsWindow:) + optional func showSettingsWindow(_ sender: Any?) + + @objc(showPreferencesWindow:) + optional func showPreferencesWindow(_ sender: Any?) +} + +@MainActor +final class SettingsWindowOpener { + static let shared = SettingsWindowOpener() + + private var openSettingsAction: OpenSettingsAction? + + func register(openSettings: OpenSettingsAction) { + self.openSettingsAction = openSettings + } + + func open() { + NSApp.activate(ignoringOtherApps: true) + if let openSettingsAction { + openSettingsAction() + return + } + + // Fallback path: mimic the built-in Settings menu item action. + let didOpen = NSApp.sendAction(#selector(SettingsWindowMenuActions.showSettingsWindow(_:)), to: nil, from: nil) + if !didOpen { + _ = NSApp.sendAction(#selector(SettingsWindowMenuActions.showPreferencesWindow(_:)), to: nil, from: nil) + } + } +} diff --git a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift index 7b4d61159..9fced5a7d 100644 --- a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift @@ -602,7 +602,7 @@ public final class GatewayDiscoveryModel { of: #"\s*-?\s*bridge$"#, with: "", options: .regularExpression) - return normalizeHostToken(strippedBridge) + return self.normalizeHostToken(strippedBridge) } } diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 8c5bab14a..94485a5e7 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -1,7 +1,7 @@ // Generated by scripts/protocol-gen-swift.ts — do not edit by hand import Foundation -public let GATEWAY_PROTOCOL_VERSION = 2 +public let GATEWAY_PROTOCOL_VERSION = 3 public enum ErrorCode: String, Codable, Sendable { case notLinked = "NOT_LINKED" @@ -1119,6 +1119,56 @@ public struct ProvidersStatusParams: Codable, Sendable { } } +public struct ProvidersStatusResult: Codable, Sendable { + public let ts: Int + public let providerorder: [String] + public let providerlabels: [String: AnyCodable] + public let providers: [String: AnyCodable] + public let provideraccounts: [String: AnyCodable] + public let providerdefaultaccountid: [String: AnyCodable] + + public init( + ts: Int, + providerorder: [String], + providerlabels: [String: AnyCodable], + providers: [String: AnyCodable], + provideraccounts: [String: AnyCodable], + providerdefaultaccountid: [String: AnyCodable] + ) { + self.ts = ts + self.providerorder = providerorder + self.providerlabels = providerlabels + self.providers = providers + self.provideraccounts = provideraccounts + self.providerdefaultaccountid = providerdefaultaccountid + } + private enum CodingKeys: String, CodingKey { + case ts + case providerorder = "providerOrder" + case providerlabels = "providerLabels" + case providers + case provideraccounts = "providerAccounts" + case providerdefaultaccountid = "providerDefaultAccountId" + } +} + +public struct ProvidersLogoutParams: Codable, Sendable { + public let provider: String + public let accountid: String? + + public init( + provider: String, + accountid: String? + ) { + self.provider = provider + self.accountid = accountid + } + private enum CodingKeys: String, CodingKey { + case provider + case accountid = "accountId" + } +} + public struct WebLoginStartParams: Codable, Sendable { public let force: Bool? public let timeoutms: Int? diff --git a/apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift index c82d211fa..aea7e8ddb 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift @@ -9,68 +9,76 @@ struct ConnectionsSettingsSmokeTests { let store = ConnectionsStore(isPreview: true) store.snapshot = ProvidersStatusSnapshot( ts: 1_700_000_000_000, - whatsapp: ProvidersStatusSnapshot.WhatsAppStatus( - configured: true, - linked: true, - authAgeMs: 86_400_000, - self: ProvidersStatusSnapshot.WhatsAppSelf( - e164: "+15551234567", - jid: nil), - running: true, - connected: false, - lastConnectedAt: 1_700_000_000_000, - lastDisconnect: ProvidersStatusSnapshot.WhatsAppDisconnect( - at: 1_700_000_050_000, - status: 401, - error: "logged out", - loggedOut: true), - reconnectAttempts: 2, - lastMessageAt: 1_700_000_060_000, - lastEventAt: 1_700_000_060_000, - lastError: "needs login"), - telegram: ProvidersStatusSnapshot.TelegramStatus( - configured: true, - tokenSource: "env", - running: true, - mode: "polling", - lastStartAt: 1_700_000_000_000, - lastStopAt: nil, - lastError: nil, - probe: ProvidersStatusSnapshot.TelegramProbe( - ok: true, - status: 200, - error: nil, - elapsedMs: 120, - bot: ProvidersStatusSnapshot.TelegramBot(id: 123, username: "clawdbotbot"), - webhook: ProvidersStatusSnapshot.TelegramWebhook( - url: "https://example.com/hook", - hasCustomCert: false)), - lastProbeAt: 1_700_000_050_000), - discord: nil, - signal: ProvidersStatusSnapshot.SignalStatus( - configured: true, - baseUrl: "http://127.0.0.1:8080", - running: true, - lastStartAt: 1_700_000_000_000, - lastStopAt: nil, - lastError: nil, - probe: ProvidersStatusSnapshot.SignalProbe( - ok: true, - status: 200, - error: nil, - elapsedMs: 140, - version: "0.12.4"), - lastProbeAt: 1_700_000_050_000), - imessage: ProvidersStatusSnapshot.IMessageStatus( - configured: false, - running: false, - lastStartAt: nil, - lastStopAt: nil, - lastError: "not configured", - cliPath: nil, - dbPath: nil, - probe: ProvidersStatusSnapshot.IMessageProbe(ok: false, error: "imsg not found (imsg)"), - lastProbeAt: 1_700_000_050_000)) + providerOrder: ["whatsapp", "telegram", "signal", "imessage"], + providerLabels: [ + "whatsapp": "WhatsApp", + "telegram": "Telegram", + "signal": "Signal", + "imessage": "iMessage", + ], + providers: [ + "whatsapp": AnyCodable([ + "configured": true, + "linked": true, + "authAgeMs": 86_400_000, + "self": ["e164": "+15551234567"], + "running": true, + "connected": false, + "lastConnectedAt": 1_700_000_000_000, + "lastDisconnect": [ + "at": 1_700_000_050_000, + "status": 401, + "error": "logged out", + "loggedOut": true, + ], + "reconnectAttempts": 2, + "lastMessageAt": 1_700_000_060_000, + "lastEventAt": 1_700_000_060_000, + "lastError": "needs login", + ]), + "telegram": AnyCodable([ + "configured": true, + "tokenSource": "env", + "running": true, + "mode": "polling", + "lastStartAt": 1_700_000_000_000, + "probe": [ + "ok": true, + "status": 200, + "elapsedMs": 120, + "bot": ["id": 123, "username": "clawdbotbot"], + "webhook": ["url": "https://example.com/hook", "hasCustomCert": false], + ], + "lastProbeAt": 1_700_000_050_000, + ]), + "signal": AnyCodable([ + "configured": true, + "baseUrl": "http://127.0.0.1:8080", + "running": true, + "lastStartAt": 1_700_000_000_000, + "probe": [ + "ok": true, + "status": 200, + "elapsedMs": 140, + "version": "0.12.4", + ], + "lastProbeAt": 1_700_000_050_000, + ]), + "imessage": AnyCodable([ + "configured": false, + "running": false, + "lastError": "not configured", + "probe": ["ok": false, "error": "imsg not found (imsg)"], + "lastProbeAt": 1_700_000_050_000, + ]), + ], + providerAccounts: [:], + providerDefaultAccountId: [ + "whatsapp": "default", + "telegram": "default", + "signal": "default", + "imessage": "default", + ]) store.whatsappLoginMessage = "Scan QR" store.whatsappLoginQrDataUrl = @@ -91,60 +99,62 @@ struct ConnectionsSettingsSmokeTests { let store = ConnectionsStore(isPreview: true) store.snapshot = ProvidersStatusSnapshot( ts: 1_700_000_000_000, - whatsapp: ProvidersStatusSnapshot.WhatsAppStatus( - configured: false, - linked: false, - authAgeMs: nil, - self: nil, - running: false, - connected: false, - lastConnectedAt: nil, - lastDisconnect: nil, - reconnectAttempts: 0, - lastMessageAt: nil, - lastEventAt: nil, - lastError: nil), - telegram: ProvidersStatusSnapshot.TelegramStatus( - configured: false, - tokenSource: nil, - running: false, - mode: nil, - lastStartAt: nil, - lastStopAt: nil, - lastError: "bot missing", - probe: ProvidersStatusSnapshot.TelegramProbe( - ok: false, - status: 403, - error: "unauthorized", - elapsedMs: 120, - bot: nil, - webhook: nil), - lastProbeAt: 1_700_000_100_000), - discord: nil, - signal: ProvidersStatusSnapshot.SignalStatus( - configured: false, - baseUrl: "http://127.0.0.1:8080", - running: false, - lastStartAt: nil, - lastStopAt: nil, - lastError: "not configured", - probe: ProvidersStatusSnapshot.SignalProbe( - ok: false, - status: 404, - error: "unreachable", - elapsedMs: 200, - version: nil), - lastProbeAt: 1_700_000_200_000), - imessage: ProvidersStatusSnapshot.IMessageStatus( - configured: false, - running: false, - lastStartAt: nil, - lastStopAt: nil, - lastError: "not configured", - cliPath: "imsg", - dbPath: nil, - probe: ProvidersStatusSnapshot.IMessageProbe(ok: false, error: "imsg not found (imsg)"), - lastProbeAt: 1_700_000_200_000)) + providerOrder: ["whatsapp", "telegram", "signal", "imessage"], + providerLabels: [ + "whatsapp": "WhatsApp", + "telegram": "Telegram", + "signal": "Signal", + "imessage": "iMessage", + ], + providers: [ + "whatsapp": AnyCodable([ + "configured": false, + "linked": false, + "running": false, + "connected": false, + "reconnectAttempts": 0, + ]), + "telegram": AnyCodable([ + "configured": false, + "running": false, + "lastError": "bot missing", + "probe": [ + "ok": false, + "status": 403, + "error": "unauthorized", + "elapsedMs": 120, + ], + "lastProbeAt": 1_700_000_100_000, + ]), + "signal": AnyCodable([ + "configured": false, + "baseUrl": "http://127.0.0.1:8080", + "running": false, + "lastError": "not configured", + "probe": [ + "ok": false, + "status": 404, + "error": "unreachable", + "elapsedMs": 200, + ], + "lastProbeAt": 1_700_000_200_000, + ]), + "imessage": AnyCodable([ + "configured": false, + "running": false, + "lastError": "not configured", + "cliPath": "imsg", + "probe": ["ok": false, "error": "imsg not found (imsg)"], + "lastProbeAt": 1_700_000_200_000, + ]), + ], + providerAccounts: [:], + providerDefaultAccountId: [ + "whatsapp": "default", + "telegram": "default", + "signal": "default", + "imessage": "default", + ]) let view = ConnectionsSettings(store: store) _ = view.body diff --git a/apps/macos/Tests/ClawdbotIPCTests/HealthDecodeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/HealthDecodeTests.swift index f9651554f..0796a05d8 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/HealthDecodeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/HealthDecodeTests.swift @@ -5,14 +5,14 @@ import Testing @Suite struct HealthDecodeTests { private let sampleJSON: String = // minimal but complete payload """ - {"ts":1733622000,"durationMs":420,"web":{"linked":true,"authAgeMs":120000,"connect":{"ok":true,"status":200,"error":null,"elapsedMs":800}},"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}} + {"ts":1733622000,"durationMs":420,"providers":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"providerOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}} """ @Test func decodesCleanJSON() async throws { let data = Data(sampleJSON.utf8) let snap = decodeHealthSnapshot(from: data) - #expect(snap?.web.linked == true) + #expect(snap?.providers["whatsapp"]?.linked == true) #expect(snap?.sessions.count == 1) } @@ -20,7 +20,7 @@ import Testing let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer" let snap = decodeHealthSnapshot(from: Data(noisy.utf8)) - #expect(snap?.web.connect?.status == 200) + #expect(snap?.providers["telegram"]?.probe?.elapsedMs == 800) } @Test func failsWithoutBraces() async throws { diff --git a/apps/macos/Tests/ClawdbotIPCTests/HealthStoreStateTests.swift b/apps/macos/Tests/ClawdbotIPCTests/HealthStoreStateTests.swift new file mode 100644 index 000000000..560e5ab5a --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/HealthStoreStateTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import Clawdbot + +@Suite struct HealthStoreStateTests { + @Test @MainActor func linkedProviderProbeFailureDegradesState() async throws { + let snap = HealthSnapshot( + ok: true, + ts: 0, + durationMs: 1, + providers: [ + "whatsapp": .init( + configured: true, + linked: true, + authAgeMs: 1, + probe: .init( + ok: false, + status: 503, + error: "gateway connect failed", + elapsedMs: 12, + bot: nil, + webhook: nil + ), + lastProbeAt: 0 + ), + ], + providerOrder: ["whatsapp"], + providerLabels: ["whatsapp": "WhatsApp"], + heartbeatSeconds: 60, + sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []) + ) + + let store = HealthStore.shared + store.__setSnapshotForTest(snap, lastError: nil) + + switch store.state { + case .degraded(let message): + #expect(!message.isEmpty) + default: + Issue.record("Expected degraded state when probe fails for linked provider") + } + + #expect(store.summaryLine.contains("probe degraded")) + } +} + diff --git a/docs/cli/index.md b/docs/cli/index.md index 42e894c01..6c6f8f73c 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -255,7 +255,7 @@ Subcommands: - `providers add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode. - `providers remove`: disable by default; pass `--delete` to remove config entries without prompts. - `providers login`: interactive provider login (WhatsApp Web only). -- `providers logout`: log out of a provider session (WhatsApp Web only). +- `providers logout`: log out of a provider session (if supported). Common options: - `--provider `: `whatsapp|telegram|discord|slack|signal|imessage|msteams` @@ -268,7 +268,7 @@ Common options: - `--verbose` `providers logout` options: -- `--provider ` (default `whatsapp`; supports `whatsapp`/`web`) +- `--provider ` (default `whatsapp`) - `--account ` `providers list` options: diff --git a/docs/concepts/provider-routing.md b/docs/concepts/provider-routing.md index ab27321e1..55443437b 100644 --- a/docs/concepts/provider-routing.md +++ b/docs/concepts/provider-routing.md @@ -1,5 +1,5 @@ --- -summary: "Routing rules per provider (WhatsApp, Telegram, Discord, web) and shared context" +summary: "Routing rules per provider (WhatsApp, Telegram, Discord, Slack) and shared context" read_when: - Changing provider routing or inbox behavior --- diff --git a/docs/concepts/typebox.md b/docs/concepts/typebox.md index 0002c726f..096e692eb 100644 --- a/docs/concepts/typebox.md +++ b/docs/concepts/typebox.md @@ -91,10 +91,11 @@ Connect (first message): "minProtocol": 2, "maxProtocol": 2, "client": { - "name": "macos", + "id": "clawdbot-macos", + "displayName": "macos", "version": "1.0.0", "platform": "macos 15.1", - "mode": "app", + "mode": "ui", "instanceId": "A1B2" } } @@ -150,10 +151,11 @@ ws.on("open", () => { id: "c1", method: "connect", params: { - minProtocol: 2, - maxProtocol: 2, + minProtocol: 3, + maxProtocol: 3, client: { - name: "example", + id: "cli", + displayName: "example", version: "dev", platform: "node", mode: "cli" diff --git a/docs/gateway/health.md b/docs/gateway/health.md index a979fad30..3a1240649 100644 --- a/docs/gateway/health.md +++ b/docs/gateway/health.md @@ -1,16 +1,16 @@ --- -summary: "Health check steps for Baileys/WhatsApp connectivity" +summary: "Health check steps for provider connectivity" read_when: - Diagnosing web provider health --- # Health Checks (CLI) -Short guide to verify the WhatsApp Web / Baileys stack without guessing. +Short guide to verify provider connectivity without guessing. ## Quick checks -- `clawdbot status` — local summary: gateway reachability/mode, update hint, creds/auth age, sessions + recent activity. +- `clawdbot status` — local summary: gateway reachability/mode, update hint, link provider auth age, sessions + recent activity. - `clawdbot status --all` — full local diagnosis (read-only, color, safe to paste for debugging). -- `clawdbot status --deep` — adds gateway health probes to status output (Telegram + Discord APIs; requires reachable gateway). +- `clawdbot status --deep` — also probes the running Gateway (per-provider probes when supported). - `clawdbot health --json` — asks the running Gateway for a full health snapshot (WS-only; no direct Baileys socket). - Send `/status` as a standalone message in WhatsApp/WebChat to get a status reply without invoking the agent. - Logs: tail `/tmp/clawdbot/clawdbot-*.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`. @@ -26,4 +26,4 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. - No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`whatsapp.groups`, `agents.list[].groupChat.mentionPatterns`). ## Dedicated "health" command -`clawdbot health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout ` to override the 10s default. +`clawdbot health --json` asks the running Gateway for its health snapshot (no direct provider sockets from the CLI). It reports linked creds/auth age when available, per-provider probe summaries, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout ` to override the 10s default. diff --git a/docs/gateway/index.md b/docs/gateway/index.md index e46c1fd44..9322fc8c6 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -103,7 +103,7 @@ CLAWDBOT_CONFIG_PATH=~/.clawdbot/b.json CLAWDBOT_STATE_DIR=~/.clawdbot-b clawdbo ``` ## Protocol (operator view) -- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth?, locale?, userAgent? } }`. +- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{id,displayName?,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId?}, caps, auth?, locale?, userAgent? } }`. - Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes). - After handshake: - Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}` @@ -259,7 +259,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above. ## Operational checks - Liveness: open WS and send `req:connect` → expect `res` with `payload.type="hello-ok"` (with snapshot). -- Readiness: call `health` → expect `ok: true` and `web.linked=true`. +- Readiness: call `health` → expect `ok: true` and a linked provider in the `providers` payload (when applicable). - Debug: subscribe to `tick` and `presence` events; ensure `status` shows linked/auth age; presence entries show Gateway host and connected clients. ## Safety guarantees diff --git a/docs/platforms/mac/health.md b/docs/platforms/mac/health.md index 4278bdaff..76bfee0bb 100644 --- a/docs/platforms/mac/health.md +++ b/docs/platforms/mac/health.md @@ -5,14 +5,14 @@ read_when: --- # Health Checks on macOS -How to see whether the WhatsApp Web/Baileys bridge is healthy from the menu bar app. +How to see whether the linked provider is healthy from the menu bar app. ## Menu bar - Status dot now reflects Baileys health: - Green: linked + socket opened recently. - Orange: connecting/retrying. - Red: logged out or probe failed. -- Secondary line reads "Web: linked · auth 12m · socket ok" or shows the failure reason. +- Secondary line reads "linked · auth 12m" or shows the failure reason. - "Run Health Check" menu item triggers an on-demand probe. ## Settings @@ -21,7 +21,7 @@ How to see whether the WhatsApp Web/Baileys bridge is healthy from the menu bar - **Connections tab** surfaces provider status + controls for WhatsApp/Telegram (login QR, logout, probe, last disconnect/error). ## How the probe works -- App runs `clawdbot health --json` via `ShellExecutor` every ~60s and on demand. The probe loads creds, attempts a short Baileys connect, and reports status without sending messages. +- App runs `clawdbot health --json` via `ShellExecutor` every ~60s and on demand. The probe loads creds and reports status without sending messages. - Cache the last good snapshot and the last error separately to avoid flicker; show the timestamp of each. ## When in doubt diff --git a/docs/refactor/provider-plugin.md b/docs/refactor/provider-plugin.md new file mode 100644 index 000000000..567a933bc --- /dev/null +++ b/docs/refactor/provider-plugin.md @@ -0,0 +1,113 @@ +--- +summary: "Provider plugin refactor implementation notes (registry, status, gateway/runtime)" +read_when: + - Adding or refactoring provider plugin wiring + - Moving provider-specific behavior into plugin hooks +--- + +# Provider Plugin Refactor — Implementation Notes + +Goal: make providers (iMessage, Discord, etc.) pluggable with minimal wiring and shared UX/state paths. + +## Architecture Overview +- Registry: `src/providers/plugins/index.ts` owns the plugin list. +- Provider dock: `src/providers/dock.ts` owns lightweight provider metadata used by shared flows (reply, command auth, block streaming) without importing full plugins. +- IDs/aliases: `src/providers/registry.ts` owns stable provider ids + input aliases. +- Shape: `src/providers/plugins/types.ts` defines the plugin contract. +- Gateway: `src/gateway/server-providers.ts` drives start/stop + runtime snapshots via plugins. +- Outbound: `src/infra/outbound/deliver.ts` routes through plugin outbound when present. +- Outbound delivery loads **outbound adapters** on-demand via `src/providers/plugins/outbound/load.ts` (avoid importing heavy provider plugins on hot paths). +- Reload: `src/gateway/config-reload.ts` uses plugin `reload.configPrefixes` lazily (avoid init cycles). +- CLI: `src/commands/providers/*` uses plugin list for add/remove/status/list. +- Protocol: `src/gateway/protocol/schema.ts` (v3) makes provider-shaped responses container-generic (maps keyed by provider id). + +## Plugin Contract (high-level) +Each `ProviderPlugin` bundles: +- `meta`: id/labels/docs/sort order. +- `capabilities`: chatTypes + optional features (polls, media, nativeCommands, etc.). +- `config`: list/resolve/default/isConfigured/describeAccount + isEnabled + (un)configured reasons + `resolveAllowFrom` + `formatAllowFrom`. +- `outbound`: deliveryMode + chunker + resolveTarget (mode-aware) + sendText/sendMedia/sendPoll + pollMaxOptions. +- `status`: defaultRuntime + probe/audit/buildAccountSnapshot + buildProviderSummary + logSelfId + collectStatusIssues. +- `gateway`: startAccount/stopAccount with runtime context (`getStatus`/`setStatus`), plus optional `loginWithQrStart/loginWithQrWait` for gateway-owned QR login flows. +- `security`: dmPolicy + allowFrom hints used by `doctor security`. +- `heartbeat`: optional readiness checks + heartbeat recipient resolution when providers own targeting. +- `auth`: optional login hook used by `clawdbot providers login`. +- `reload`: `configPrefixes` that map to hot restarts. +- `onboarding`: optional CLI onboarding adapter (wizard UI hooks per provider). +- `agentTools`: optional provider-owned agent tools (ex: QR login). + +## Key Integration Notes +- `listProviderPlugins()` is the runtime source of truth for provider UX and wiring. +- Avoid importing `src/providers/plugins/index.ts` from shared modules (reply flow, command auth, sandbox explain). It’s intentionally “heavy” (providers may pull web login / monitor code). Use `getProviderDock()` + `normalizeProviderId()` for cheap metadata, and only `getProviderPlugin()` at execution boundaries (ex: `src/auto-reply/reply/route-reply.ts`). +- WhatsApp plugin keeps Baileys-heavy login bits behind lazy imports; cheap auth file checks live in `src/web/auth-store.ts` (so outbound routing doesn’t pay Baileys import cost). +- `routeReply` delegates sending to plugin `outbound` adapters via a lazy import of `src/infra/outbound/deliver.ts` (so adding a provider is “just implement outbound adapter”, no router switches). +- Avoid static imports of provider monitors inside plugin modules. Monitors typically import the reply pipeline, which can create ESM cycles (and break Vite/Vitest SSR with TDZ errors). Prefer lazy imports inside `gateway.startAccount`. +- Debug cycle leaks quickly with: `npx -y madge --circular src/providers/plugins/index.ts`. +- Gateway protocol schema keeps provider selection as an open-ended string (no provider enum / static list) to avoid init cycles and so new plugins don’t require protocol changes. +- Protocol v3: no more per-provider fields in `providers.status`; consumers must read map entries by provider id. +- `DEFAULT_CHAT_PROVIDER` lives in `src/providers/registry.ts` and is used anywhere we need a fallback delivery surface. +- Provider reload rules are computed lazily to avoid static init cycles in tests. +- Signal/iMessage media size limits are now resolved inside their plugins. +- `normalizeProviderId()` handles aliases (ex: `imsg`, `teams`) so CLI and API inputs stay stable. +- `ProviderId` is `ChatProviderId` (no extra special-cased provider IDs in shared code). +- Gateway runtime defaults (`status.defaultRuntime`) replace the old per-provider runtime map. +- Gateway runtime snapshot (`getRuntimeSnapshot`) is map-based: `{ providers, providerAccounts }` (no `${id}Accounts` keys). +- `providers.status` response keys (v3): + - `providerOrder: string[]` + - `providerLabels: Record` + - `providers: Record` (provider summary objects, plugin-defined) + - `providerAccounts: Record` + - `providerDefaultAccountId: Record` +- `providers.status` summary objects come from `status.buildProviderSummary` (no per-provider branching in the handler). +- `providers.status` warnings now flow through `status.collectStatusIssues` per plugin. +- CLI list uses `meta.showConfigured` to decide whether to show configured state. +- CLI provider options and prompt provider lists are generated from `listProviderPlugins()` (avoid hardcoded arrays). +- Provider selection (`resolveMessageProviderSelection`) now inspects `config.isEnabled` + `config.isConfigured` per plugin instead of hardcoded provider checks. +- Pairing flows (CLI + store) now use `plugin.pairing` (`idLabel`, `normalizeAllowEntry`, `notifyApproval`) via `src/providers/plugins/pairing.ts`. +- CLI provider remove/disable delegates to `config.setAccountEnabled` + `config.deleteAccount` per plugin. +- CLI provider add now delegates to `plugin.setup` for account validation, naming, and config writes (no hardcoded provider checks). +- Agent provider status entries are now built from plugin config/status (`status.resolveAccountState` for custom state labels). +- Agent binding defaults use `meta.forceAccountBinding` to avoid hardcoded provider checks. +- Onboarding quickstart allowlist uses `meta.quickstartAllowFrom` to avoid hardcoded provider lists. +- `resolveProviderDefaultAccountId()` is the shared helper for picking default accounts from `accountIds` + plugin config. +- `routeReply` uses plugin outbound senders; `ProviderOutboundContext` supports `replyToId` + `threadId` and outbound delivery supports `abortSignal` for cooperative cancellation. +- Outbound target resolution (`resolveOutboundTarget`) now delegates to `plugin.outbound.resolveTarget` (mode-aware, uses config allowlists when present). +- Outbound delivery results accept `meta` for provider-specific fields to avoid core type churn in new plugins. +- Agent gateway routing sets `deliveryTargetMode` and uses `resolveOutboundTarget` for implicit fallback targets when `to` is missing. +- Elevated tool allowlists (`tools.elevated.allowFrom`) are a record keyed by provider id (no schema update needed when adding providers). +- Block streaming defaults live on the plugin (`capabilities.blockStreaming`, `streaming.blockStreamingCoalesceDefaults`) instead of hardcoded provider checks. +- Provider logout now routes through `providers.logout` using `gateway.logoutAccount` on each plugin (clients should call the generic method). +- Gateway message-provider normalization uses `src/providers/registry.ts` for cheap validation/normalization without plugin init cycles. +- Group mention gating now flows through `plugin.groups.resolveRequireMention` (Discord/Slack/Telegram/WhatsApp/iMessage) instead of branching in reply handlers. +- Command authorization uses `config.resolveAllowFrom` + `config.formatAllowFrom`, with `commands.enforceOwnerForCommands` and `commands.skipWhenConfigEmpty` driving provider-specific behavior. +- Security warnings (`doctor security`) use `plugin.security.resolveDmPolicy` + `plugin.security.collectWarnings`; supply `policyPath` + `allowFromPath` for accurate config hints. +- Reply threading uses `plugin.threading.resolveReplyToMode` and `plugin.threading.allowTagsWhenOff` rather than provider switches in reply helpers. +- Tool auto-threading context flows through `plugin.threading.buildToolContext` (e.g., Slack threadTs injection). +- Messaging tool dedupe now relies on `plugin.messaging.normalizeTarget` for provider-specific target normalization. +- Message tool + CLI action dispatch now use `plugin.actions.listActions` + `plugin.actions.handleAction`; use `plugin.actions.supportsAction` for dispatch-only gating when you still want fallback send/poll. +- Session announce targets can opt into `meta.preferSessionLookupForAnnounceTarget` when session keys are insufficient (e.g., WhatsApp). +- Onboarding provider setup is delegated to adapter modules under `src/providers/plugins/onboarding/*`, keeping `setupProviders` provider-agnostic. +- Onboarding registry now reads `plugin.onboarding` from each provider (no standalone onboarding map). +- Provider login flows (`clawdbot providers login`) route through `plugin.auth.login` when available. +- `clawdbot status` reports `linkProvider` (derived from `status.buildProviderSummary().linked`) instead of a hardcoded `web` provider field. +- Gateway `web.login.*` methods use `plugin.gatewayMethods` ownership to pick the provider (no hardcoded `normalizeProviderId("web")` in the handler). + +## CLI Commands (inline references) +- Add/remove providers: `clawdbot providers add ` / `clawdbot providers remove `. +- Inspect provider state: `clawdbot providers list`, `clawdbot providers status`. +- Link/unlink providers: `clawdbot providers login --provider ` / `clawdbot providers logout --provider `. +- Pairing approvals: `clawdbot pairing list `, `clawdbot pairing approve `. + +## Adding a Provider (checklist) +1) Create `src/providers/plugins/.ts` exporting `ProviderPlugin`. +2) Register in `src/providers/plugins/index.ts` and update `src/providers/registry.ts` (ids/aliases/meta) if needed. +3) Add a dock entry in `src/providers/dock.ts` for any shared behavior (capabilities, allowFrom format/resolve, mention stripping, threading, streaming chunk defaults). +4) Add `reload.configPrefixes` for hot reload when config changes. +5) Delegate to existing provider modules (send/probe/monitor) or create them. +6) If you changed the gateway protocol: run `pnpm protocol:check` (updates `dist/protocol.schema.json` + `apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`). +7) Update docs/tests for any behavior changes. + +## Cleanup Expectations +- Keep plugin files small; move heavy logic into provider modules. +- Prefer shared helpers over V2 copies. +- Update docs when behavior/inputs change. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index e0af2fe16..67de20874 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -40,11 +40,9 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe Text + native (when enabled): - `/help` - `/commands` -- `/whoami` (alias: `/id`) -- `/status` - `/status` (show current status; includes a short usage line when available) - `/usage` (alias: `/status`) -- `/whoami` (alias: `/id`) +- `/whoami` (show your sender id; alias: `/id`) - `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`) - `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`) - `/cost on|off` (toggle per-response usage line) diff --git a/scripts/e2e/gateway-network-docker.sh b/scripts/e2e/gateway-network-docker.sh index 5614239e4..97f765298 100644 --- a/scripts/e2e/gateway-network-docker.sh +++ b/scripts/e2e/gateway-network-docker.sh @@ -89,7 +89,8 @@ ws.send( minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { - name: \"docker-net-e2e\", + id: \"test\", + displayName: \"docker-net-e2e\", version: \"dev\", platform: process.platform, mode: \"test\", @@ -112,4 +113,3 @@ console.log(\"ok\"); NODE" echo "OK" - diff --git a/src/agents/claude-cli-runner.ts b/src/agents/claude-cli-runner.ts index 23e129334..f2dd81aa7 100644 --- a/src/agents/claude-cli-runner.ts +++ b/src/agents/claude-cli-runner.ts @@ -1 +1,3 @@ +// Backwards-compatible entry point. +// Implementation lives in `src/agents/cli-runner.ts` (so we can reuse the same runner for other CLIs). export { runClaudeCliAgent, runCliAgent } from "./cli-runner.js"; diff --git a/src/agents/pi-embedded-messaging.ts b/src/agents/pi-embedded-messaging.ts index 87d2adaae..a8a1dc737 100644 --- a/src/agents/pi-embedded-messaging.ts +++ b/src/agents/pi-embedded-messaging.ts @@ -1,3 +1,8 @@ +import { + getProviderPlugin, + normalizeProviderId, +} from "../providers/plugins/index.js"; + export type MessagingToolSend = { tool: string; provider: string; @@ -5,101 +10,29 @@ export type MessagingToolSend = { to?: string; }; -const MESSAGING_TOOLS = new Set([ - "telegram", - "whatsapp", - "discord", - "slack", - "sessions_send", - "message", -]); +const CORE_MESSAGING_TOOLS = new Set(["sessions_send", "message"]); +// Provider docking: any plugin with `actions` opts into messaging tool handling. export function isMessagingTool(toolName: string): boolean { - return MESSAGING_TOOLS.has(toolName); + if (CORE_MESSAGING_TOOLS.has(toolName)) return true; + const providerId = normalizeProviderId(toolName); + return Boolean(providerId && getProviderPlugin(providerId)?.actions); } export function isMessagingToolSendAction( toolName: string, - actionRaw: string, + args: Record, ): boolean { - const action = actionRaw.trim(); + const action = typeof args.action === "string" ? args.action.trim() : ""; if (toolName === "sessions_send") return true; if (toolName === "message") { return action === "send" || action === "thread-reply"; } - return action === "sendMessage" || action === "threadReply"; -} - -function normalizeSlackTarget(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) return undefined; - const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i); - if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase(); - if (trimmed.startsWith("user:")) { - const id = trimmed.slice(5).trim(); - return id ? `user:${id}`.toLowerCase() : undefined; - } - if (trimmed.startsWith("channel:")) { - const id = trimmed.slice(8).trim(); - return id ? `channel:${id}`.toLowerCase() : undefined; - } - if (trimmed.startsWith("slack:")) { - const id = trimmed.slice(6).trim(); - return id ? `user:${id}`.toLowerCase() : undefined; - } - if (trimmed.startsWith("@")) { - const id = trimmed.slice(1).trim(); - return id ? `user:${id}`.toLowerCase() : undefined; - } - if (trimmed.startsWith("#")) { - const id = trimmed.slice(1).trim(); - return id ? `channel:${id}`.toLowerCase() : undefined; - } - return `channel:${trimmed}`.toLowerCase(); -} - -function normalizeDiscordTarget(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) return undefined; - const mentionMatch = trimmed.match(/^<@!?(\d+)>$/); - if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase(); - if (trimmed.startsWith("user:")) { - const id = trimmed.slice(5).trim(); - return id ? `user:${id}`.toLowerCase() : undefined; - } - if (trimmed.startsWith("channel:")) { - const id = trimmed.slice(8).trim(); - return id ? `channel:${id}`.toLowerCase() : undefined; - } - if (trimmed.startsWith("discord:")) { - const id = trimmed.slice(8).trim(); - return id ? `user:${id}`.toLowerCase() : undefined; - } - if (trimmed.startsWith("@")) { - const id = trimmed.slice(1).trim(); - return id ? `user:${id}`.toLowerCase() : undefined; - } - return `channel:${trimmed}`.toLowerCase(); -} - -function normalizeTelegramTarget(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) return undefined; - let normalized = trimmed; - if (normalized.startsWith("telegram:")) { - normalized = normalized.slice("telegram:".length).trim(); - } else if (normalized.startsWith("tg:")) { - normalized = normalized.slice("tg:".length).trim(); - } else if (normalized.startsWith("group:")) { - normalized = normalized.slice("group:".length).trim(); - } - if (!normalized) return undefined; - const tmeMatch = - /^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ?? - /^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized); - if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`; - if (!normalized) return undefined; - return `telegram:${normalized}`.toLowerCase(); + const providerId = normalizeProviderId(toolName); + if (!providerId) return false; + const plugin = getProviderPlugin(providerId); + if (!plugin?.actions?.extractToolSend) return false; + return Boolean(plugin.actions.extractToolSend({ args })?.to); } export function normalizeTargetForProvider( @@ -107,14 +40,10 @@ export function normalizeTargetForProvider( raw?: string, ): string | undefined { if (!raw) return undefined; - switch (provider.trim().toLowerCase()) { - case "slack": - return normalizeSlackTarget(raw); - case "discord": - return normalizeDiscordTarget(raw); - case "telegram": - return normalizeTelegramTarget(raw); - default: - return raw.trim().toLowerCase() || undefined; - } + const providerId = normalizeProviderId(provider); + const plugin = providerId ? getProviderPlugin(providerId) : undefined; + const normalized = + plugin?.messaging?.normalizeTarget?.(raw) ?? + (raw.trim().toLowerCase() || undefined); + return normalized || undefined; } diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 4cd8ce07c..48516bf54 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -117,6 +117,7 @@ import { type SkillSnapshot, } from "./skills.js"; import { buildAgentSystemPrompt } from "./system-prompt.js"; +import { buildToolSummaryMap } from "./tool-summaries.js"; import { normalizeUsage, type UsageLike } from "./usage.js"; import { filterBootstrapFilesForSession, @@ -644,6 +645,7 @@ function buildEmbeddedSystemPrompt(params: { runtimeInfo: params.runtimeInfo, sandboxInfo: params.sandboxInfo, toolNames: params.tools.map((tool) => tool.name), + toolSummaries: buildToolSummaryMap(params.tools), modelAliasLines: params.modelAliasLines, userTimezone: params.userTimezone, userTime: params.userTime, diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 24bfdf865..9d3d325e9 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -9,6 +9,10 @@ import { formatToolAggregate } from "../auto-reply/tool-meta.js"; import { resolveStateDir } from "../config/paths.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { createSubsystemLogger } from "../logging.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../providers/plugins/index.js"; import { truncateUtf16Safe } from "../utils.js"; import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; @@ -142,59 +146,37 @@ function extractMessagingToolSend( toolName: string, args: Record, ): MessagingToolSend | undefined { + // Provider docking: new provider tools must implement plugin.actions.extractToolSend. const action = typeof args.action === "string" ? args.action.trim() : ""; const accountIdRaw = typeof args.accountId === "string" ? args.accountId.trim() : undefined; const accountId = accountIdRaw ? accountIdRaw : undefined; - if (toolName === "slack") { - if (action !== "sendMessage") return undefined; - const toRaw = typeof args.to === "string" ? args.to : undefined; - if (!toRaw) return undefined; - const to = normalizeTargetForProvider("slack", toRaw); - return to - ? { tool: toolName, provider: "slack", accountId, to } - : undefined; - } - if (toolName === "discord") { - if (action === "sendMessage") { - const toRaw = typeof args.to === "string" ? args.to : undefined; - if (!toRaw) return undefined; - const to = normalizeTargetForProvider("discord", toRaw); - return to - ? { tool: toolName, provider: "discord", accountId, to } - : undefined; - } - if (action === "threadReply") { - const channelId = - typeof args.channelId === "string" ? args.channelId.trim() : ""; - if (!channelId) return undefined; - const to = normalizeTargetForProvider("discord", `channel:${channelId}`); - return to - ? { tool: toolName, provider: "discord", accountId, to } - : undefined; - } - return undefined; - } - if (toolName === "telegram") { - if (action !== "sendMessage") return undefined; - const toRaw = typeof args.to === "string" ? args.to : undefined; - if (!toRaw) return undefined; - const to = normalizeTargetForProvider("telegram", toRaw); - return to - ? { tool: toolName, provider: "telegram", accountId, to } - : undefined; - } if (toolName === "message") { if (action !== "send" && action !== "thread-reply") return undefined; const toRaw = typeof args.to === "string" ? args.to : undefined; if (!toRaw) return undefined; const providerRaw = typeof args.provider === "string" ? args.provider.trim() : ""; - const provider = providerRaw ? providerRaw.toLowerCase() : "message"; + const providerId = providerRaw ? normalizeProviderId(providerRaw) : null; + const provider = + providerId ?? (providerRaw ? providerRaw.toLowerCase() : "message"); const to = normalizeTargetForProvider(provider, toRaw); return to ? { tool: toolName, provider, accountId, to } : undefined; } - return undefined; + const providerId = normalizeProviderId(toolName); + if (!providerId) return undefined; + const plugin = getProviderPlugin(providerId); + const extracted = plugin?.actions?.extractToolSend?.({ args }); + if (!extracted?.to) return undefined; + const to = normalizeTargetForProvider(providerId, extracted.to); + return to + ? { + tool: toolName, + provider: providerId, + accountId: extracted.accountId ?? accountId, + to, + } + : undefined; } export function subscribeEmbeddedPiSession(params: { @@ -564,7 +546,10 @@ export function subscribeEmbeddedPiSession(params: { typeof argsRecord.action === "string" ? argsRecord.action.trim() : ""; - const isMessagingSend = isMessagingToolSendAction(toolName, action); + const isMessagingSend = isMessagingToolSendAction( + toolName, + argsRecord, + ); if (isMessagingSend) { const sendTarget = extractMessagingToolSend(toolName, argsRecord); if (sendTarget) { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 54706bf54..898a8bc18 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -6,12 +6,10 @@ import { createWriteTool, readTool, } from "@mariozechner/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../config/config.js"; import { detectMime } from "../media/mime.js"; import { isSubagentSessionKey } from "../routing/session-key.js"; import { resolveGatewayMessageProvider } from "../utils/message-provider.js"; -import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; import { resolveAgentConfig, resolveAgentIdFromSessionKey, @@ -24,6 +22,7 @@ import { } from "./bash-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; import type { ModelAuthMode } from "./model-auth.js"; +import { listProviderAgentTools } from "./provider-tools.js"; import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js"; import { assertSandboxPath } from "./sandbox-paths.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; @@ -400,75 +399,6 @@ function createSandboxedEditTool(root: string) { return wrapSandboxPathGuard(base as unknown as AnyAgentTool, root); } -function createWhatsAppLoginTool(): AnyAgentTool { - return { - label: "WhatsApp Login", - name: "whatsapp_login", - description: - "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", - // NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)]) - // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. - parameters: Type.Object({ - action: Type.Unsafe<"start" | "wait">({ - type: "string", - enum: ["start", "wait"], - }), - timeoutMs: Type.Optional(Type.Number()), - force: Type.Optional(Type.Boolean()), - }), - execute: async (_toolCallId, args) => { - const action = (args as { action?: string })?.action ?? "start"; - if (action === "wait") { - const result = await waitForWebLogin({ - timeoutMs: - typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" - ? (args as { timeoutMs?: number }).timeoutMs - : undefined, - }); - return { - content: [{ type: "text", text: result.message }], - details: { connected: result.connected }, - }; - } - - const result = await startWebLoginWithQr({ - timeoutMs: - typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" - ? (args as { timeoutMs?: number }).timeoutMs - : undefined, - force: - typeof (args as { force?: unknown }).force === "boolean" - ? (args as { force?: boolean }).force - : false, - }); - - if (!result.qrDataUrl) { - return { - content: [ - { - type: "text", - text: result.message, - }, - ], - details: { qr: false }, - }; - } - - const text = [ - result.message, - "", - "Open WhatsApp → Linked Devices and scan:", - "", - `![whatsapp-qr](${result.qrDataUrl})`, - ].join("\n"); - return { - content: [{ type: "text", text }], - details: { qr: true }, - }; - }, - }; -} - function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool { return { ...base, @@ -635,7 +565,8 @@ export function createClawdbotCodingTools(options?: { : []), bashTool as unknown as AnyAgentTool, processTool as unknown as AnyAgentTool, - createWhatsAppLoginTool(), + // Provider docking: include provider-defined agent tools (login, etc.). + ...listProviderAgentTools({ cfg: options?.config }), ...createClawdbotTools({ browserControlUrl: sandbox?.browser?.controlUrl, allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, diff --git a/src/agents/provider-tools.ts b/src/agents/provider-tools.ts new file mode 100644 index 000000000..f3dd973a7 --- /dev/null +++ b/src/agents/provider-tools.ts @@ -0,0 +1,17 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { listProviderPlugins } from "../providers/plugins/index.js"; +import type { ProviderAgentTool } from "../providers/plugins/types.js"; + +export function listProviderAgentTools(params: { + cfg?: ClawdbotConfig; +}): ProviderAgentTool[] { + // Provider docking: aggregate provider-owned tools (login, etc.). + const tools: ProviderAgentTool[] = []; + for (const plugin of listProviderPlugins()) { + const entry = plugin.agentTools; + if (!entry) continue; + const resolved = typeof entry === "function" ? entry(params) : entry; + if (Array.isArray(resolved)) tools.push(...resolved); + } + return tools; +} diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 7732becce..94b90c9e6 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -19,6 +19,7 @@ import { loadConfig, STATE_DIR_CLAWDBOT, } from "../config/config.js"; +import { PROVIDER_IDS } from "../providers/registry.js"; import { buildAgentMainSessionKey, normalizeAgentId, @@ -176,13 +177,14 @@ const DEFAULT_TOOL_ALLOW = [ "sessions_spawn", "session_status", ]; +// Provider docking: keep sandbox policy aligned with provider tool names. const DEFAULT_TOOL_DENY = [ "browser", "canvas", "nodes", "cron", - "discord", "gateway", + ...PROVIDER_IDS, ]; export const DEFAULT_SANDBOX_BROWSER_IMAGE = "clawdbot-sandbox-browser:bookworm-slim"; diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 27fad87a6..9264a5599 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -1,7 +1,10 @@ import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { PROVIDER_IDS } from "../providers/registry.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +const MESSAGE_PROVIDER_OPTIONS = PROVIDER_IDS.join("|"); + export function buildAgentSystemPrompt(params: { workspaceDir: string; defaultThinkLevel?: ThinkLevel; @@ -10,6 +13,7 @@ export function buildAgentSystemPrompt(params: { ownerNumbers?: string[]; reasoningTagHint?: boolean; toolNames?: string[]; + toolSummaries?: Record; modelAliasLines?: string[]; userTimezone?: string; userTime?: string; @@ -42,7 +46,7 @@ export function buildAgentSystemPrompt(params: { }; }; }) { - const toolSummaries: Record = { + const coreToolSummaries: Record = { read: "Read file contents", write: "Create or overwrite files", edit: "Make precise edits to files", @@ -51,7 +55,7 @@ export function buildAgentSystemPrompt(params: { ls: "List directory contents", bash: "Run shell commands", process: "Manage background bash sessions", - whatsapp_login: "Generate and wait for WhatsApp QR login", + // Provider docking: add provider login tools here when a provider needs interactive linking. browser: "Control web browser", canvas: "Present/eval/snapshot the Canvas", nodes: "List/describe/notify/camera/screen on paired nodes", @@ -78,7 +82,6 @@ export function buildAgentSystemPrompt(params: { "ls", "bash", "process", - "whatsapp_login", "browser", "canvas", "nodes", @@ -107,17 +110,25 @@ export function buildAgentSystemPrompt(params: { const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase()); const availableTools = new Set(normalizedTools); + const externalToolSummaries = new Map(); + for (const [key, value] of Object.entries(params.toolSummaries ?? {})) { + const normalized = key.trim().toLowerCase(); + if (!normalized || !value?.trim()) continue; + externalToolSummaries.set(normalized, value.trim()); + } const extraTools = Array.from( new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))), ); const enabledTools = toolOrder.filter((tool) => availableTools.has(tool)); const toolLines = enabledTools.map((tool) => { - const summary = toolSummaries[tool]; + const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool); const name = resolveToolName(tool); return summary ? `- ${name}: ${summary}` : `- ${name}`; }); for (const tool of extraTools.sort()) { - toolLines.push(`- ${resolveToolName(tool)}`); + const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool); + const name = resolveToolName(tool); + toolLines.push(summary ? `- ${name}: ${summary}` : `- ${name}`); } const hasGateway = availableTools.has("gateway"); @@ -160,9 +171,7 @@ export function buildAgentSystemPrompt(params: { const runtimeCapabilitiesLower = new Set( runtimeCapabilities.map((cap) => cap.toLowerCase()), ); - const telegramInlineButtonsEnabled = - runtimeProvider === "telegram" && - runtimeCapabilitiesLower.has("inlinebuttons"); + const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); const skillsLines = skillsPrompt ? [skillsPrompt, ""] : []; const skillsSection = skillsPrompt ? [ @@ -188,7 +197,6 @@ export function buildAgentSystemPrompt(params: { "- ls: list directory contents", `- ${bashToolName}: run shell commands (supports background via yieldMs/background)`, `- ${processToolName}: manage background bash sessions`, - "- whatsapp_login: generate a WhatsApp QR code and wait for linking", "- browser: control clawd's dedicated browser", "- canvas: present/eval/snapshot the Canvas", "- nodes: list/describe/notify/camera/screen on paired nodes", @@ -314,11 +322,11 @@ export function buildAgentSystemPrompt(params: { "### message tool", "- Use `message` for proactive sends + provider actions (polls, reactions, etc.).", "- For `action=send`, include `to` and `message`.", - "- If multiple providers are configured, pass `provider` (whatsapp|telegram|discord|slack|signal|imessage|msteams).", - telegramInlineButtonsEnabled - ? "- Telegram: inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." - : runtimeProvider === "telegram" - ? '- Telegram: inline buttons NOT enabled. If you need them, ask to add "inlineButtons" to telegram.capabilities or telegram.accounts..capabilities.' + `- If multiple providers are configured, pass \`provider\` (${MESSAGE_PROVIDER_OPTIONS}).`, + inlineButtonsEnabled + ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." + : runtimeProvider + ? `- Inline buttons not enabled for ${runtimeProvider}. If you need them, ask to add "inlineButtons" to ${runtimeProvider}.capabilities or ${runtimeProvider}.accounts..capabilities.` : "", ] .filter(Boolean) diff --git a/src/agents/tool-summaries.ts b/src/agents/tool-summaries.ts new file mode 100644 index 000000000..325966b41 --- /dev/null +++ b/src/agents/tool-summaries.ts @@ -0,0 +1,13 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; + +export function buildToolSummaryMap( + tools: AgentTool[], +): Record { + const summaries: Record = {}; + for (const tool of tools) { + const summary = tool.description?.trim() || tool.label?.trim(); + if (!summary) continue; + summaries[tool.name.toLowerCase()] = summary; + } + return summaries; +} diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index c0ca1b36b..c676ad050 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -1,4 +1,8 @@ import { callGateway } from "../../gateway/call.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../utils/message-provider.js"; export const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; @@ -39,7 +43,8 @@ export async function callGatewayTool( params, timeoutMs: gateway.timeoutMs, expectFinal: extra?.expectFinal, - clientName: "agent", - mode: "agent", + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: "agent", + mode: GATEWAY_CLIENT_MODES.BACKEND, }); } diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index a0dd8808a..d3fff0a87 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -3,7 +3,6 @@ import { Type } from "@sinclair/typebox"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; -import { listEnabledDiscordAccounts } from "../../discord/accounts.js"; import { type MessagePollResult, type MessageSendResult, @@ -11,22 +10,24 @@ import { sendPoll, } from "../../infra/outbound/message.js"; import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js"; -import { resolveMSTeamsCredentials } from "../../msteams/token.js"; +import { + dispatchProviderMessageAction, + listProviderMessageActions, + supportsProviderMessageButtons, +} from "../../providers/plugins/message-actions.js"; +import type { ProviderMessageActionName } from "../../providers/plugins/types.js"; import { normalizeAccountId } from "../../routing/session-key.js"; -import { listEnabledSlackAccounts } from "../../slack/accounts.js"; -import { listEnabledTelegramAccounts } from "../../telegram/accounts.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../utils/message-provider.js"; import type { AnyAgentTool } from "./common.js"; import { - createActionGate, jsonResult, readNumberParam, readStringArrayParam, readStringParam, } from "./common.js"; -import { handleDiscordAction } from "./discord-actions.js"; -import { handleSlackAction } from "./slack-actions.js"; -import { handleTelegramAction } from "./telegram-actions.js"; -import { handleWhatsAppAction } from "./whatsapp-actions.js"; const AllMessageActions = [ "send", @@ -64,6 +65,8 @@ const AllMessageActions = [ const MessageToolCommonSchema = { provider: Type.Optional(Type.String()), + to: Type.Optional(Type.String()), + message: Type.Optional(Type.String()), media: Type.Optional(Type.String()), buttons: Type.Optional( Type.Array( @@ -155,8 +158,6 @@ function buildMessageToolSchemaFromActions( action: Type.Union( nonSendActions.map((action) => Type.Literal(action)), ), - to: Type.Optional(Type.String()), - message: Type.Optional(Type.String()), ...props, }), ); @@ -172,163 +173,19 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { type MessageToolOptions = { agentAccountId?: string; config?: ClawdbotConfig; - /** Current channel ID for auto-threading (Slack). */ currentChannelId?: string; - /** Current thread timestamp for auto-threading (Slack). */ currentThreadTs?: string; - /** Reply-to mode for Slack auto-threading. */ replyToMode?: "off" | "first" | "all"; - /** Mutable ref to track if a reply was sent (for "first" mode). */ hasRepliedRef?: { value: boolean }; }; -function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean { - const caps = new Set(); - for (const entry of cfg.telegram?.capabilities ?? []) { - const trimmed = String(entry).trim(); - if (trimmed) caps.add(trimmed.toLowerCase()); - } - const accounts = cfg.telegram?.accounts; - if (accounts && typeof accounts === "object") { - for (const account of Object.values(accounts)) { - const accountCaps = (account as { capabilities?: unknown })?.capabilities; - if (!Array.isArray(accountCaps)) continue; - for (const entry of accountCaps) { - const trimmed = String(entry).trim(); - if (trimmed) caps.add(trimmed.toLowerCase()); - } - } - } - return caps.has("inlinebuttons"); -} - -function buildMessageActionList(cfg: ClawdbotConfig) { - const actions = new Set(["send"]); - - const discordAccounts = listEnabledDiscordAccounts(cfg).filter( - (account) => account.tokenSource !== "none", - ); - const discordEnabled = discordAccounts.length > 0; - const discordGate = createActionGate(cfg.discord?.actions); - - const slackAccounts = listEnabledSlackAccounts(cfg).filter( - (account) => account.botTokenSource !== "none", - ); - const slackEnabled = slackAccounts.length > 0; - const isSlackActionEnabled = (key: string, defaultValue = true) => { - if (!slackEnabled) return false; - for (const account of slackAccounts) { - const gate = createActionGate( - (account.actions ?? cfg.slack?.actions) as Record< - string, - boolean | undefined - >, - ); - if (gate(key, defaultValue)) return true; - } - return false; - }; - - const telegramAccounts = listEnabledTelegramAccounts(cfg).filter( - (account) => account.tokenSource !== "none", - ); - const telegramEnabled = telegramAccounts.length > 0; - const telegramGate = createActionGate(cfg.telegram?.actions); - - const whatsappGate = createActionGate(cfg.whatsapp?.actions); - - const canDiscordReactions = discordEnabled && discordGate("reactions"); - const canSlackReactions = isSlackActionEnabled("reactions"); - const canTelegramReactions = telegramEnabled && telegramGate("reactions"); - const canWhatsAppReactions = cfg.whatsapp ? whatsappGate("reactions") : false; - const canAnyReactions = - canDiscordReactions || - canSlackReactions || - canTelegramReactions || - canWhatsAppReactions; - if (canAnyReactions) actions.add("react"); - if (canDiscordReactions || canSlackReactions) actions.add("reactions"); - - const canDiscordMessages = discordEnabled && discordGate("messages"); - const canSlackMessages = isSlackActionEnabled("messages"); - if (canDiscordMessages || canSlackMessages) { - actions.add("read"); - actions.add("edit"); - actions.add("delete"); - } - - const canDiscordPins = discordEnabled && discordGate("pins"); - const canSlackPins = isSlackActionEnabled("pins"); - if (canDiscordPins || canSlackPins) { - actions.add("pin"); - actions.add("unpin"); - actions.add("list-pins"); - } - - const msteamsEnabled = - cfg.msteams?.enabled !== false && - Boolean(cfg.msteams && resolveMSTeamsCredentials(cfg.msteams)); - const canDiscordPolls = discordEnabled && discordGate("polls"); - const canWhatsAppPolls = cfg.whatsapp ? whatsappGate("polls") : false; - if (canDiscordPolls || canWhatsAppPolls || msteamsEnabled) - actions.add("poll"); - if (discordEnabled && discordGate("permissions")) actions.add("permissions"); - if (discordEnabled && discordGate("threads")) { - actions.add("thread-create"); - actions.add("thread-list"); - actions.add("thread-reply"); - } - if (discordEnabled && discordGate("search")) actions.add("search"); - if (discordEnabled && discordGate("stickers")) actions.add("sticker"); - if ( - (discordEnabled && discordGate("memberInfo")) || - isSlackActionEnabled("memberInfo") - ) { - actions.add("member-info"); - } - if (discordEnabled && discordGate("roleInfo")) actions.add("role-info"); - if ( - (discordEnabled && discordGate("reactions")) || - isSlackActionEnabled("emojiList") - ) { - actions.add("emoji-list"); - } - if (discordEnabled && discordGate("emojiUploads")) - actions.add("emoji-upload"); - if (discordEnabled && discordGate("stickerUploads")) - actions.add("sticker-upload"); - - const canDiscordRoles = discordEnabled && discordGate("roles", false); - if (canDiscordRoles) { - actions.add("role-add"); - actions.add("role-remove"); - } - - if (discordEnabled && discordGate("channelInfo")) { - actions.add("channel-info"); - actions.add("channel-list"); - } - if (discordEnabled && discordGate("voiceStatus")) actions.add("voice-status"); - if (discordEnabled && discordGate("events")) { - actions.add("event-list"); - actions.add("event-create"); - } - if (discordEnabled && discordGate("moderation", false)) { - actions.add("timeout"); - actions.add("kick"); - actions.add("ban"); - } - - return Array.from(actions); -} - function buildMessageToolSchema(cfg: ClawdbotConfig) { - const actions = buildMessageActionList(cfg); - const telegramEnabled = listEnabledTelegramAccounts(cfg).some( - (account) => account.tokenSource !== "none", + const actions = listProviderMessageActions(cfg); + const includeButtons = supportsProviderMessageButtons(cfg); + return buildMessageToolSchemaFromActions( + actions.length > 0 ? actions : ["send"], + { includeButtons }, ); - const includeButtons = telegramEnabled && hasTelegramInlineButtons(cfg); - return buildMessageToolSchemaFromActions(actions, { includeButtons }); } function resolveAgentAccountId(value?: string): string | undefined { @@ -342,30 +199,49 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const schema = options?.config ? buildMessageToolSchema(options.config) : MessageToolSchema; + return { label: "Message", name: "message", description: - "Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).", + "Send messages and provider actions (polls, reactions, pins, threads, etc.) via configured provider plugins.", parameters: schema, execute: async (_toolCallId, args) => { const params = args as Record; const cfg = options?.config ?? loadConfig(); - const action = readStringParam(params, "action", { required: true }); + const action = readStringParam(params, "action", { + required: true, + }) as ProviderMessageActionName; + const providerSelection = await resolveMessageProviderSelection({ cfg, provider: readStringParam(params, "provider"), }); const provider = providerSelection.provider; const accountId = readStringParam(params, "accountId") ?? agentAccountId; + const dryRun = Boolean(params.dryRun); + const gateway = { url: readStringParam(params, "gatewayUrl", { trim: false }), token: readStringParam(params, "gatewayToken", { trim: false }), timeoutMs: readNumberParam(params, "timeoutMs"), - clientName: "agent" as const, - mode: "agent" as const, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: "agent", + mode: GATEWAY_CLIENT_MODES.BACKEND, }; - const dryRun = Boolean(params.dryRun); + + const toolContext = + options?.currentChannelId || + options?.currentThreadTs || + options?.replyToMode || + options?.hasRepliedRef + ? { + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + replyToMode: options?.replyToMode, + hasRepliedRef: options?.hasRepliedRef, + } + : undefined; if (action === "send") { const to = readStringParam(params, "to", { required: true }); @@ -373,14 +249,19 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { required: true, allowEmpty: true, }); + + // Let send accept the same inline directives we use elsewhere. + // Provider plugins consume `replyTo` / `media` / `buttons` from params. const parsed = parseReplyDirectives(message); message = parsed.text; - const mediaUrl = - readStringParam(params, "media", { trim: false }) ?? - (parsed.mediaUrls?.[0] || parsed.mediaUrl); - const replyTo = readStringParam(params, "replyTo") ?? parsed.replyToId; - const threadId = readStringParam(params, "threadId"); - const buttons = params.buttons; + params.message = message; + if (!params.replyTo && parsed.replyToId) + params.replyTo = parsed.replyToId; + if (!params.media) { + params.media = parsed.mediaUrls?.[0] || parsed.mediaUrl || undefined; + } + + const mediaUrl = readStringParam(params, "media", { trim: false }); const gifPlayback = typeof params.gifPlayback === "boolean" ? params.gifPlayback : false; const bestEffort = @@ -403,52 +284,17 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { return jsonResult(result); } - if (provider === "discord") { - return await handleDiscordAction( - { - action: "sendMessage", - to, - content: message, - mediaUrl: mediaUrl ?? undefined, - replyTo: replyTo ?? undefined, - }, - cfg, - ); - } - if (provider === "slack") { - return await handleSlackAction( - { - action: "sendMessage", - to, - content: message, - mediaUrl: mediaUrl ?? undefined, - accountId: accountId ?? undefined, - threadTs: threadId ?? replyTo ?? undefined, - }, - cfg, - { - currentChannelId: options?.currentChannelId, - currentThreadTs: options?.currentThreadTs, - replyToMode: options?.replyToMode, - hasRepliedRef: options?.hasRepliedRef, - }, - ); - } - if (provider === "telegram") { - return await handleTelegramAction( - { - action: "sendMessage", - to, - content: message, - mediaUrl: mediaUrl ?? undefined, - replyToMessageId: replyTo ?? undefined, - messageThreadId: threadId ?? undefined, - accountId: accountId ?? undefined, - buttons, - }, - cfg, - ); - } + const handled = await dispatchProviderMessageAction({ + provider, + action, + cfg, + params, + accountId, + gateway, + toolContext, + dryRun, + }); + if (handled) return handled; const result: MessageSendResult = await sendMessage({ to, @@ -477,10 +323,11 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { integer: true, }); + const maxSelections = allowMultiselect + ? Math.max(2, options.length) + : 1; + if (dryRun) { - const maxSelections = allowMultiselect - ? Math.max(2, options.length) - : 1; const result: MessagePollResult = await sendPoll({ to, question, @@ -494,24 +341,18 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { return jsonResult(result); } - if (provider === "discord") { - return await handleDiscordAction( - { - action: "poll", - to, - question, - answers: options, - allowMultiselect, - durationHours: durationHours ?? undefined, - content: readStringParam(params, "message"), - }, - cfg, - ); - } + const handled = await dispatchProviderMessageAction({ + provider, + action, + cfg, + params, + accountId, + gateway, + toolContext, + dryRun, + }); + if (handled) return handled; - const maxSelections = allowMultiselect - ? Math.max(2, options.length) - : 1; const result: MessagePollResult = await sendPoll({ to, question, @@ -525,626 +366,21 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { return jsonResult(result); } - const resolveChannelId = (label: string) => - readStringParam(params, label) ?? - readStringParam(params, "to", { required: true }); + const handled = await dispatchProviderMessageAction({ + provider, + action, + cfg, + params, + accountId, + gateway, + toolContext, + dryRun, + }); + if (handled) return handled; - const resolveChatId = (label: string) => - readStringParam(params, label) ?? - readStringParam(params, "to", { required: true }); - - if (action === "react") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = - typeof params.remove === "boolean" ? params.remove : undefined; - if (provider === "discord") { - return await handleDiscordAction( - { - action: "react", - channelId: resolveChannelId("channelId"), - messageId, - emoji, - remove, - }, - cfg, - ); - } - if (provider === "slack") { - return await handleSlackAction( - { - action: "react", - channelId: resolveChannelId("channelId"), - messageId, - emoji, - remove, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - if (provider === "telegram") { - return await handleTelegramAction( - { - action: "react", - chatId: resolveChatId("chatId"), - messageId, - emoji, - remove, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - if (provider === "whatsapp") { - return await handleWhatsAppAction( - { - action: "react", - chatJid: resolveChatId("chatJid"), - messageId, - emoji, - remove, - participant: readStringParam(params, "participant"), - accountId: accountId ?? undefined, - fromMe: - typeof params.fromMe === "boolean" ? params.fromMe : undefined, - }, - cfg, - ); - } - throw new Error(`React is not supported for provider ${provider}.`); - } - - if (action === "reactions") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const limit = readNumberParam(params, "limit", { integer: true }); - if (provider === "discord") { - return await handleDiscordAction( - { - action: "reactions", - channelId: resolveChannelId("channelId"), - messageId, - limit, - }, - cfg, - ); - } - if (provider === "slack") { - return await handleSlackAction( - { - action: "reactions", - channelId: resolveChannelId("channelId"), - messageId, - limit, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - throw new Error( - `Reactions are not supported for provider ${provider}.`, - ); - } - - if (action === "read") { - const limit = readNumberParam(params, "limit", { integer: true }); - const before = readStringParam(params, "before"); - const after = readStringParam(params, "after"); - const around = readStringParam(params, "around"); - if (provider === "discord") { - return await handleDiscordAction( - { - action: "readMessages", - channelId: resolveChannelId("channelId"), - limit, - before, - after, - around, - }, - cfg, - ); - } - if (provider === "slack") { - return await handleSlackAction( - { - action: "readMessages", - channelId: resolveChannelId("channelId"), - limit, - before, - after, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - throw new Error(`Read is not supported for provider ${provider}.`); - } - - if (action === "edit") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - const message = readStringParam(params, "message", { required: true }); - if (provider === "discord") { - return await handleDiscordAction( - { - action: "editMessage", - channelId: resolveChannelId("channelId"), - messageId, - content: message, - }, - cfg, - ); - } - if (provider === "slack") { - return await handleSlackAction( - { - action: "editMessage", - channelId: resolveChannelId("channelId"), - messageId, - content: message, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - throw new Error(`Edit is not supported for provider ${provider}.`); - } - - if (action === "delete") { - const messageId = readStringParam(params, "messageId", { - required: true, - }); - if (provider === "discord") { - return await handleDiscordAction( - { - action: "deleteMessage", - channelId: resolveChannelId("channelId"), - messageId, - }, - cfg, - ); - } - if (provider === "slack") { - return await handleSlackAction( - { - action: "deleteMessage", - channelId: resolveChannelId("channelId"), - messageId, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - throw new Error(`Delete is not supported for provider ${provider}.`); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const messageId = - action === "list-pins" - ? undefined - : readStringParam(params, "messageId", { required: true }); - const channelId = resolveChannelId("channelId"); - if (provider === "discord") { - const discordAction = - action === "pin" - ? "pinMessage" - : action === "unpin" - ? "unpinMessage" - : "listPins"; - return await handleDiscordAction( - { - action: discordAction, - channelId, - messageId, - }, - cfg, - ); - } - if (provider === "slack") { - const slackAction = - action === "pin" - ? "pinMessage" - : action === "unpin" - ? "unpinMessage" - : "listPins"; - return await handleSlackAction( - { - action: slackAction, - channelId, - messageId, - accountId: accountId ?? undefined, - }, - cfg, - ); - } - throw new Error(`Pins are not supported for provider ${provider}.`); - } - - if (action === "permissions") { - if (provider !== "discord") { - throw new Error( - `Permissions are only supported for Discord (provider=${provider}).`, - ); - } - return await handleDiscordAction( - { - action: "permissions", - channelId: resolveChannelId("channelId"), - }, - cfg, - ); - } - - if (action === "thread-create") { - if (provider !== "discord") { - throw new Error( - `Thread create is only supported for Discord (provider=${provider}).`, - ); - } - const name = readStringParam(params, "threadName", { required: true }); - const messageId = readStringParam(params, "messageId"); - const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { - integer: true, - }); - return await handleDiscordAction( - { - action: "threadCreate", - channelId: resolveChannelId("channelId"), - name, - messageId, - autoArchiveMinutes, - }, - cfg, - ); - } - - if (action === "thread-list") { - if (provider !== "discord") { - throw new Error( - `Thread list is only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - const channelId = readStringParam(params, "channelId"); - const includeArchived = - typeof params.includeArchived === "boolean" - ? params.includeArchived - : undefined; - const before = readStringParam(params, "before"); - const limit = readNumberParam(params, "limit", { integer: true }); - return await handleDiscordAction( - { - action: "threadList", - guildId, - channelId, - includeArchived, - before, - limit, - }, - cfg, - ); - } - - if (action === "thread-reply") { - if (provider !== "discord") { - throw new Error( - `Thread reply is only supported for Discord (provider=${provider}).`, - ); - } - let content = readStringParam(params, "message", { required: true }); - const parsed = parseReplyDirectives(content); - content = parsed.text; - const mediaUrl = - readStringParam(params, "media", { trim: false }) ?? - (parsed.mediaUrls?.[0] || parsed.mediaUrl); - const replyTo = readStringParam(params, "replyTo") ?? parsed.replyToId; - return await handleDiscordAction( - { - action: "threadReply", - channelId: resolveChannelId("channelId"), - content, - mediaUrl: mediaUrl ?? undefined, - replyTo: replyTo ?? undefined, - }, - cfg, - ); - } - - if (action === "search") { - if (provider !== "discord") { - throw new Error( - `Search is only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - const query = readStringParam(params, "query", { required: true }); - const channelId = readStringParam(params, "channelId"); - const channelIds = readStringArrayParam(params, "channelIds"); - const authorId = readStringParam(params, "authorId"); - const authorIds = readStringArrayParam(params, "authorIds"); - const limit = readNumberParam(params, "limit", { integer: true }); - return await handleDiscordAction( - { - action: "searchMessages", - guildId, - content: query, - channelId, - channelIds, - authorId, - authorIds, - limit, - }, - cfg, - ); - } - - if (action === "sticker") { - if (provider !== "discord") { - throw new Error( - `Sticker send is only supported for Discord (provider=${provider}).`, - ); - } - const stickerIds = - readStringArrayParam(params, "stickerId", { - required: true, - label: "sticker-id", - }) ?? []; - const content = readStringParam(params, "message"); - return await handleDiscordAction( - { - action: "sticker", - to: readStringParam(params, "to", { required: true }), - stickerIds, - content, - }, - cfg, - ); - } - - if (action === "member-info") { - const userId = readStringParam(params, "userId", { required: true }); - if (provider === "discord") { - const guildId = readStringParam(params, "guildId", { - required: true, - }); - return await handleDiscordAction( - { action: "memberInfo", guildId, userId }, - cfg, - ); - } - if (provider === "slack") { - return await handleSlackAction( - { action: "memberInfo", userId, accountId: accountId ?? undefined }, - cfg, - ); - } - throw new Error( - `Member info is not supported for provider ${provider}.`, - ); - } - - if (action === "role-info") { - if (provider !== "discord") { - throw new Error( - `Role info is only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - return await handleDiscordAction({ action: "roleInfo", guildId }, cfg); - } - - if (action === "emoji-list") { - if (provider === "discord") { - const guildId = readStringParam(params, "guildId", { - required: true, - }); - return await handleDiscordAction( - { action: "emojiList", guildId }, - cfg, - ); - } - if (provider === "slack") { - return await handleSlackAction( - { action: "emojiList", accountId: accountId ?? undefined }, - cfg, - ); - } - throw new Error( - `Emoji list is not supported for provider ${provider}.`, - ); - } - - if (action === "emoji-upload") { - if (provider !== "discord") { - throw new Error( - `Emoji upload is only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - const name = readStringParam(params, "emojiName", { required: true }); - const mediaUrl = readStringParam(params, "media", { - required: true, - trim: false, - }); - const roleIds = readStringArrayParam(params, "roleIds"); - return await handleDiscordAction( - { - action: "emojiUpload", - guildId, - name, - mediaUrl, - roleIds, - }, - cfg, - ); - } - - if (action === "sticker-upload") { - if (provider !== "discord") { - throw new Error( - `Sticker upload is only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - const name = readStringParam(params, "stickerName", { required: true }); - const description = readStringParam(params, "stickerDesc", { - required: true, - }); - const tags = readStringParam(params, "stickerTags", { required: true }); - const mediaUrl = readStringParam(params, "media", { - required: true, - trim: false, - }); - return await handleDiscordAction( - { - action: "stickerUpload", - guildId, - name, - description, - tags, - mediaUrl, - }, - cfg, - ); - } - - if (action === "role-add" || action === "role-remove") { - if (provider !== "discord") { - throw new Error( - `Role changes are only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - const userId = readStringParam(params, "userId", { required: true }); - const roleId = readStringParam(params, "roleId", { required: true }); - const discordAction = action === "role-add" ? "roleAdd" : "roleRemove"; - return await handleDiscordAction( - { action: discordAction, guildId, userId, roleId }, - cfg, - ); - } - - if (action === "channel-info") { - if (provider !== "discord") { - throw new Error( - `Channel info is only supported for Discord (provider=${provider}).`, - ); - } - const channelId = readStringParam(params, "channelId", { - required: true, - }); - return await handleDiscordAction( - { action: "channelInfo", channelId }, - cfg, - ); - } - - if (action === "channel-list") { - if (provider !== "discord") { - throw new Error( - `Channel list is only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - return await handleDiscordAction( - { action: "channelList", guildId }, - cfg, - ); - } - - if (action === "voice-status") { - if (provider !== "discord") { - throw new Error( - `Voice status is only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - const userId = readStringParam(params, "userId", { required: true }); - return await handleDiscordAction( - { action: "voiceStatus", guildId, userId }, - cfg, - ); - } - - if (action === "event-list") { - if (provider !== "discord") { - throw new Error( - `Event list is only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - return await handleDiscordAction({ action: "eventList", guildId }, cfg); - } - - if (action === "event-create") { - if (provider !== "discord") { - throw new Error( - `Event create is only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - const name = readStringParam(params, "eventName", { required: true }); - const startTime = readStringParam(params, "startTime", { - required: true, - }); - const endTime = readStringParam(params, "endTime"); - const description = readStringParam(params, "desc"); - const channelId = readStringParam(params, "channelId"); - const location = readStringParam(params, "location"); - const entityType = readStringParam(params, "eventType"); - return await handleDiscordAction( - { - action: "eventCreate", - guildId, - name, - startTime, - endTime, - description, - channelId, - location, - entityType, - }, - cfg, - ); - } - - if (action === "timeout" || action === "kick" || action === "ban") { - if (provider !== "discord") { - throw new Error( - `Moderation actions are only supported for Discord (provider=${provider}).`, - ); - } - const guildId = readStringParam(params, "guildId", { required: true }); - const userId = readStringParam(params, "userId", { required: true }); - const durationMinutes = readNumberParam(params, "durationMin", { - integer: true, - }); - const until = readStringParam(params, "until"); - const reason = readStringParam(params, "reason"); - const deleteMessageDays = readNumberParam(params, "deleteDays", { - integer: true, - }); - const discordAction = action as "timeout" | "kick" | "ban"; - return await handleDiscordAction( - { - action: discordAction, - guildId, - userId, - durationMinutes, - until, - reason, - deleteMessageDays, - }, - cfg, - ); - } - - throw new Error(`Unknown action: ${action}`); + throw new Error( + `Message action ${action} not supported for provider ${provider}.`, + ); }, }; } diff --git a/src/agents/tools/sessions-announce-target.ts b/src/agents/tools/sessions-announce-target.ts index 4a0b66dc9..3fb2aea92 100644 --- a/src/agents/tools/sessions-announce-target.ts +++ b/src/agents/tools/sessions-announce-target.ts @@ -1,4 +1,8 @@ import { callGateway } from "../../gateway/call.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../../providers/plugins/index.js"; import type { AnnounceTarget } from "./sessions-send-helpers.js"; import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js"; @@ -10,9 +14,13 @@ export async function resolveAnnounceTarget(params: { const parsedDisplay = resolveAnnounceTargetFromKey(params.displayKey); const fallback = parsed ?? parsedDisplay ?? null; - // Most providers can derive (provider,to) from the session key directly. - // WhatsApp is special: we may need lastAccountId from the session store. - if (fallback && fallback.provider !== "whatsapp") return fallback; + if (fallback) { + const normalized = normalizeProviderId(fallback.provider); + const plugin = normalized ? getProviderPlugin(normalized) : null; + if (!plugin?.meta?.preferSessionLookupForAnnounceTarget) { + return fallback; + } + } try { const list = (await callGateway({ diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts index cc2b995d1..640be7e9c 100644 --- a/src/agents/tools/sessions-send-helpers.ts +++ b/src/agents/tools/sessions-send-helpers.ts @@ -1,4 +1,8 @@ import type { ClawdbotConfig } from "../../config/config.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../../providers/plugins/index.js"; const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP"; const REPLY_SKIP_TOKEN = "REPLY_SKIP"; @@ -25,14 +29,19 @@ export function resolveAnnounceTargetFromKey( const id = rest.join(":").trim(); if (!id) return null; if (!providerRaw) return null; - const provider = providerRaw.toLowerCase(); - if (provider === "discord") { - return { provider, to: `channel:${id}` }; - } - if (provider === "signal") { - return { provider, to: `group:${id}` }; - } - return { provider, to: id }; + const normalizedProvider = normalizeProviderId(providerRaw); + const provider = normalizedProvider ?? providerRaw.toLowerCase(); + const kindTarget = normalizedProvider + ? kind === "channel" + ? `channel:${id}` + : `group:${id}` + : id; + const normalized = normalizedProvider + ? getProviderPlugin(normalizedProvider)?.messaging?.normalizeTarget?.( + kindTarget, + ) + : undefined; + return { provider, to: normalized ?? kindTarget }; } export function buildAgentToAgentMessageContext(params: { diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index ddd3478f2..407ea19c4 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -117,7 +117,12 @@ describe("resolveTextChunkLimit", () => { expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000); expect(resolveTextChunkLimit(undefined, "signal")).toBe(4000); expect(resolveTextChunkLimit(undefined, "imessage")).toBe(4000); - expect(resolveTextChunkLimit(undefined, "discord")).toBe(2000); + expect(resolveTextChunkLimit(undefined, "discord")).toBe(4000); + expect( + resolveTextChunkLimit(undefined, "discord", undefined, { + fallbackLimit: 2000, + }), + ).toBe(2000); }); it("supports provider overrides", () => { diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 793e74e0c..8a6b2bc88 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -8,80 +8,63 @@ import { isSafeFenceBreak, parseFenceSpans, } from "../markdown/fences.js"; +import type { ProviderId } from "../providers/plugins/types.js"; import { normalizeAccountId } from "../routing/session-key.js"; +import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js"; -export type TextChunkProvider = - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "webchat" - | "msteams"; +export type TextChunkProvider = ProviderId | typeof INTERNAL_MESSAGE_PROVIDER; -const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record = { - whatsapp: 4000, - telegram: 4000, - discord: 2000, - slack: 4000, - signal: 4000, - imessage: 4000, - webchat: 4000, - msteams: 4000, +const DEFAULT_CHUNK_LIMIT = 4000; + +type ProviderChunkConfig = { + textChunkLimit?: number; + accounts?: Record; }; +function resolveChunkLimitForProvider( + cfgSection: ProviderChunkConfig | undefined, + accountId?: string | null, +): number | undefined { + if (!cfgSection) return undefined; + const normalizedAccountId = normalizeAccountId(accountId); + const accounts = cfgSection.accounts; + if (accounts && typeof accounts === "object") { + const direct = accounts[normalizedAccountId]; + if (typeof direct?.textChunkLimit === "number") { + return direct.textChunkLimit; + } + const matchKey = Object.keys(accounts).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ); + const match = matchKey ? accounts[matchKey] : undefined; + if (typeof match?.textChunkLimit === "number") { + return match.textChunkLimit; + } + } + return cfgSection.textChunkLimit; +} + export function resolveTextChunkLimit( cfg: ClawdbotConfig | undefined, provider?: TextChunkProvider, accountId?: string | null, + opts?: { fallbackLimit?: number }, ): number { + const fallback = + typeof opts?.fallbackLimit === "number" && opts.fallbackLimit > 0 + ? opts.fallbackLimit + : DEFAULT_CHUNK_LIMIT; const providerOverride = (() => { - if (!provider) return undefined; - const normalizedAccountId = normalizeAccountId(accountId); - if (provider === "whatsapp") { - return cfg?.whatsapp?.textChunkLimit; - } - if (provider === "telegram") { - return ( - cfg?.telegram?.accounts?.[normalizedAccountId]?.textChunkLimit ?? - cfg?.telegram?.textChunkLimit - ); - } - if (provider === "discord") { - return ( - cfg?.discord?.accounts?.[normalizedAccountId]?.textChunkLimit ?? - cfg?.discord?.textChunkLimit - ); - } - if (provider === "slack") { - return ( - cfg?.slack?.accounts?.[normalizedAccountId]?.textChunkLimit ?? - cfg?.slack?.textChunkLimit - ); - } - if (provider === "signal") { - return ( - cfg?.signal?.accounts?.[normalizedAccountId]?.textChunkLimit ?? - cfg?.signal?.textChunkLimit - ); - } - if (provider === "imessage") { - return ( - cfg?.imessage?.accounts?.[normalizedAccountId]?.textChunkLimit ?? - cfg?.imessage?.textChunkLimit - ); - } - if (provider === "msteams") { - return cfg?.msteams?.textChunkLimit; - } - return undefined; + if (!provider || provider === INTERNAL_MESSAGE_PROVIDER) return undefined; + const providerConfig = (cfg as Record | undefined)?.[ + provider + ] as ProviderChunkConfig | undefined; + return resolveChunkLimitForProvider(providerConfig, accountId); })(); if (typeof providerOverride === "number" && providerOverride > 0) { return providerOverride; } - if (provider) return DEFAULT_CHUNK_LIMIT_BY_PROVIDER[provider]; - return 4000; + return fallback; } export function chunkText(text: string, limit: number): string[] { diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index 744971951..a31ecbe49 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -1,72 +1,123 @@ import type { ClawdbotConfig } from "../config/config.js"; -import { normalizeE164 } from "../utils.js"; +import type { ProviderDock } from "../providers/dock.js"; +import { getProviderDock, listProviderDocks } from "../providers/dock.js"; +import type { ProviderId } from "../providers/plugins/types.js"; +import { normalizeProviderId } from "../providers/registry.js"; import type { MsgContext } from "./templating.js"; export type CommandAuthorization = { - isWhatsAppProvider: boolean; + providerId?: ProviderId; ownerList: string[]; - senderE164?: string; + senderId?: string; isAuthorizedSender: boolean; from?: string; to?: string; }; +function resolveProviderFromContext( + ctx: MsgContext, + cfg: ClawdbotConfig, +): ProviderId | undefined { + const direct = + normalizeProviderId(ctx.Provider) ?? + normalizeProviderId(ctx.Surface) ?? + normalizeProviderId(ctx.OriginatingChannel); + if (direct) return direct; + const candidates = [ctx.From, ctx.To] + .filter((value): value is string => Boolean(value?.trim())) + .flatMap((value) => value.split(":").map((part) => part.trim())); + for (const candidate of candidates) { + const normalized = normalizeProviderId(candidate); + if (normalized) return normalized; + } + const configured = listProviderDocks() + .map((dock) => { + if (!dock.config?.resolveAllowFrom) return null; + const allowFrom = dock.config.resolveAllowFrom({ + cfg, + accountId: ctx.AccountId, + }); + if (!Array.isArray(allowFrom) || allowFrom.length === 0) return null; + return dock.id; + }) + .filter((value): value is ProviderId => Boolean(value)); + if (configured.length === 1) return configured[0]; + return undefined; +} + +function formatAllowFromList(params: { + dock?: ProviderDock; + cfg: ClawdbotConfig; + accountId?: string | null; + allowFrom: Array; +}): string[] { + const { dock, cfg, accountId, allowFrom } = params; + if (!allowFrom || allowFrom.length === 0) return []; + if (dock?.config?.formatAllowFrom) { + return dock.config.formatAllowFrom({ cfg, accountId, allowFrom }); + } + return allowFrom.map((entry) => String(entry).trim()).filter(Boolean); +} + export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: ClawdbotConfig; commandAuthorized: boolean; }): CommandAuthorization { const { ctx, cfg, commandAuthorized } = params; - const provider = (ctx.Provider ?? "").trim().toLowerCase(); - const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); - const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); - const hasWhatsappPrefix = - (ctx.From ?? "").startsWith("whatsapp:") || - (ctx.To ?? "").startsWith("whatsapp:"); - const looksLikeE164 = (value: string) => - Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, ""))); - const inferWhatsApp = - !provider && - Boolean(cfg.whatsapp?.allowFrom?.length) && - (looksLikeE164(from) || looksLikeE164(to)); - const isWhatsAppProvider = - provider === "whatsapp" || hasWhatsappPrefix || inferWhatsApp; - - const configuredAllowFrom = isWhatsAppProvider - ? cfg.whatsapp?.allowFrom - : undefined; - const allowFromList = - configuredAllowFrom?.filter((entry) => entry?.trim()) ?? []; + const providerId = resolveProviderFromContext(ctx, cfg); + const dock = providerId ? getProviderDock(providerId) : undefined; + const from = (ctx.From ?? "").trim(); + const to = (ctx.To ?? "").trim(); + const allowFromRaw = dock?.config?.resolveAllowFrom + ? dock.config.resolveAllowFrom({ cfg, accountId: ctx.AccountId }) + : []; + const allowFromList = formatAllowFromList({ + dock, + cfg, + accountId: ctx.AccountId, + allowFrom: Array.isArray(allowFromRaw) ? allowFromRaw : [], + }); const allowAll = - !isWhatsAppProvider || allowFromList.length === 0 || allowFromList.some((entry) => entry.trim() === "*"); - const senderE164 = normalizeE164( - ctx.SenderE164 ?? (isWhatsAppProvider ? from : ""), - ); - const ownerCandidates = - isWhatsAppProvider && !allowAll - ? allowFromList.filter((entry) => entry !== "*") - : []; - if (isWhatsAppProvider && !allowAll && ownerCandidates.length === 0 && to) { - ownerCandidates.push(to); + const ownerCandidates = allowAll + ? [] + : allowFromList.filter((entry) => entry !== "*"); + if (!allowAll && ownerCandidates.length === 0 && to) { + const normalizedTo = formatAllowFromList({ + dock, + cfg, + accountId: ctx.AccountId, + allowFrom: [to], + })[0]; + if (normalizedTo) ownerCandidates.push(normalizedTo); } - const ownerList = ownerCandidates - .map((entry) => normalizeE164(entry)) - .filter((entry): entry is string => Boolean(entry)); + const ownerList = ownerCandidates; + const senderRaw = ctx.SenderId ?? ctx.SenderE164 ?? from; + const senderId = senderRaw + ? formatAllowFromList({ + dock, + cfg, + accountId: ctx.AccountId, + allowFrom: [senderRaw], + })[0] + : undefined; + + const enforceOwner = Boolean(dock?.commands?.enforceOwnerForCommands); const isOwner = - !isWhatsAppProvider || + !enforceOwner || allowAll || ownerList.length === 0 || - (senderE164 ? ownerList.includes(senderE164) : false); + (senderId ? ownerList.includes(senderId) : false); const isAuthorizedSender = commandAuthorized && isOwner; return { - isWhatsAppProvider, + providerId, ownerList, - senderE164: senderE164 || undefined, + senderId: senderId || undefined, isAuthorizedSender, from: from || undefined, to: to || undefined, diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts index e7dcba238..7b55f82d9 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-detection.test.ts @@ -43,9 +43,8 @@ describe("control command parsing", () => { expect(hasControlCommand("/commands")).toBe(true); expect(hasControlCommand("/commands:")).toBe(true); expect(hasControlCommand("commands")).toBe(false); - expect(hasControlCommand("/compact")).toBe(true); - expect(hasControlCommand("/compact:")).toBe(true); - expect(hasControlCommand("compact")).toBe(false); + expect(hasControlCommand("/status")).toBe(true); + expect(hasControlCommand("/status:")).toBe(true); expect(hasControlCommand("status")).toBe(false); expect(hasControlCommand("usage")).toBe(false); @@ -55,6 +54,9 @@ describe("control command parsing", () => { expect(hasControlCommand(`${alias}:`)).toBe(true); } } + expect(hasControlCommand("/compact")).toBe(true); + expect(hasControlCommand("/compact:")).toBe(true); + expect(hasControlCommand("compact")).toBe(false); }); it("respects disabled config/debug commands", () => { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index a420e5977..2d79a7a36 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -1,4 +1,5 @@ import type { ClawdbotConfig } from "../config/types.js"; +import { listProviderDocks } from "../providers/dock.js"; export type CommandScope = "text" | "native" | "both"; @@ -263,8 +264,18 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { assertCommandRegistry(commands); return commands; })(); +let cachedNativeCommandSurfaces: Set | null = null; -const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]); +const getNativeCommandSurfaces = (): Set => { + if (!cachedNativeCommandSurfaces) { + cachedNativeCommandSurfaces = new Set( + listProviderDocks() + .filter((dock) => dock.capabilities.nativeCommands) + .map((dock) => dock.id), + ); + } + return cachedNativeCommandSurfaces; +}; const TEXT_ALIAS_MAP: Map = (() => { const map = new Map(); @@ -354,14 +365,18 @@ export function normalizeCommandBody(raw: string): string { const trimmed = raw.trim(); if (!trimmed.startsWith("/")) return trimmed; - const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/); + const newline = trimmed.indexOf("\n"); + const singleLine = + newline === -1 ? trimmed : trimmed.slice(0, newline).trim(); + + const colonMatch = singleLine.match(/^\/([^\s:]+)\s*:(.*)$/); const normalized = colonMatch ? (() => { const [, command, rest] = colonMatch; const normalizedRest = rest.trimStart(); return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`; })() - : trimmed; + : singleLine; const lowered = normalized.toLowerCase(); const exact = TEXT_ALIAS_MAP.get(lowered); @@ -380,44 +395,86 @@ export function normalizeCommandBody(raw: string): string { : tokenSpec.canonical; } -export function getCommandDetection(): { exact: Set; regex: RegExp } { +export function isCommandMessage(raw: string): boolean { + const trimmed = normalizeCommandBody(raw); + return trimmed.startsWith("/"); +} + +export function getCommandDetection(_cfg?: ClawdbotConfig): { + exact: Set; + regex: RegExp; +} { if (cachedDetection) return cachedDetection; const exact = new Set(); const patterns: string[] = []; - for (const command of CHAT_COMMANDS) { - for (const alias of command.textAliases) { + for (const cmd of CHAT_COMMANDS) { + for (const alias of cmd.textAliases) { const normalized = alias.trim().toLowerCase(); if (!normalized) continue; exact.add(normalized); const escaped = escapeRegExp(normalized); if (!escaped) continue; - if (command.acceptsArgs) { + if (cmd.acceptsArgs) { patterns.push(`${escaped}(?:\\s+.+|\\s*:\\s*.*)?`); } else { patterns.push(`${escaped}(?:\\s*:\\s*)?`); } } } - const regex = patterns.length - ? new RegExp(`^(?:${patterns.join("|")})$`, "i") - : /$^/; - cachedDetection = { exact, regex }; + cachedDetection = { + exact, + regex: patterns.length + ? new RegExp(`^(?:${patterns.join("|")})$`, "i") + : /$^/, + }; return cachedDetection; } -export function supportsNativeCommands(surface?: string): boolean { +export function maybeResolveTextAlias(raw: string, cfg?: ClawdbotConfig) { + const trimmed = normalizeCommandBody(raw).trim(); + if (!trimmed.startsWith("/")) return null; + const detection = getCommandDetection(cfg); + const normalized = trimmed.toLowerCase(); + if (detection.exact.has(normalized)) return normalized; + if (!detection.regex.test(normalized)) return null; + const tokenMatch = normalized.match(/^\/([^\s:]+)(?:\s|$)/); + if (!tokenMatch) return null; + const tokenKey = `/${tokenMatch[1]}`; + return TEXT_ALIAS_MAP.has(tokenKey) ? tokenKey : null; +} + +export function resolveTextCommand( + raw: string, + cfg?: ClawdbotConfig, +): { + command: ChatCommandDefinition; + args?: string; +} | null { + const trimmed = normalizeCommandBody(raw).trim(); + const alias = maybeResolveTextAlias(trimmed, cfg); + if (!alias) return null; + const spec = TEXT_ALIAS_MAP.get(alias); + if (!spec) return null; + const command = CHAT_COMMANDS.find( + (entry) => `/${entry.key}` === spec.canonical, + ); + if (!command) return null; + if (!spec.acceptsArgs) return { command }; + const args = trimmed.slice(alias.length).trim(); + return { command, args: args || undefined }; +} + +export function isNativeCommandSurface(surface?: string): boolean { if (!surface) return false; - return NATIVE_COMMAND_SURFACES.has(surface.toLowerCase()); + return getNativeCommandSurfaces().has(surface.toLowerCase()); } export function shouldHandleTextCommands(params: { cfg: ClawdbotConfig; - surface?: string; + surface: string; commandSource?: "text" | "native"; }): boolean { - const { cfg, surface, commandSource } = params; - const textEnabled = cfg.commands?.text !== false; - if (commandSource === "native") return true; - if (textEnabled) return true; - return !supportsNativeCommands(surface); + if (params.commandSource === "native") return true; + if (params.cfg.commands?.text !== false) return true; + return !isNativeCommandSurface(params.surface); } diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index d8afe0a33..2e7113579 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -800,7 +800,7 @@ describe("trigger handling", () => { }); }); - it("falls back to discord dm allowFrom for elevated approval", async () => { + it("uses tools.elevated.allowFrom.discord for elevated approval", async () => { await withTempHome(async (home) => { const cfg = { agents: { @@ -809,11 +809,7 @@ describe("trigger handling", () => { workspace: join(home, "clawd"), }, }, - discord: { - dm: { - allowFrom: ["steipete"], - }, - }, + tools: { elevated: { allowFrom: { discord: ["steipete"] } } }, session: { store: join(home, "sessions.json") }, }; @@ -856,11 +852,6 @@ describe("trigger handling", () => { allowFrom: { discord: [] }, }, }, - discord: { - dm: { - allowFrom: ["steipete"], - }, - }, session: { store: join(home, "sessions.json") }, }; diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 423c638e9..05c2ea186 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -32,8 +32,14 @@ import { import { resolveSessionFilePath } from "../config/sessions.js"; import { logVerbose } from "../globals.js"; import { clearCommandLane, getQueueSize } from "../process/command-queue.js"; +import { getProviderDock } from "../providers/dock.js"; +import { + CHAT_PROVIDER_ORDER, + normalizeProviderId, +} from "../providers/registry.js"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; +import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js"; import { resolveCommandAuthorization } from "./command-auth.js"; import { hasControlCommand } from "./command-detection.js"; import { @@ -127,53 +133,41 @@ function slugAllowToken(value?: string) { return text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); } +const SENDER_PREFIXES = [ + ...CHAT_PROVIDER_ORDER, + INTERNAL_MESSAGE_PROVIDER, + "user", + "group", + "channel", +]; +const SENDER_PREFIX_RE = new RegExp(`^(${SENDER_PREFIXES.join("|")}):`, "i"); + function stripSenderPrefix(value?: string) { if (!value) return ""; const trimmed = value.trim(); - return trimmed.replace( - /^(whatsapp|telegram|discord|signal|imessage|webchat|user|group|channel):/i, - "", - ); + return trimmed.replace(SENDER_PREFIX_RE, ""); } function resolveElevatedAllowList( allowFrom: AgentElevatedAllowFromConfig | undefined, provider: string, - discordFallback?: Array, + fallbackAllowFrom?: Array, ): Array | undefined { - switch (provider) { - case "whatsapp": - return allowFrom?.whatsapp; - case "telegram": - return allowFrom?.telegram; - case "discord": { - const hasExplicit = Boolean( - allowFrom && Object.hasOwn(allowFrom, "discord"), - ); - if (hasExplicit) return allowFrom?.discord; - return discordFallback; - } - case "signal": - return allowFrom?.signal; - case "imessage": - return allowFrom?.imessage; - case "webchat": - return allowFrom?.webchat; - default: - return undefined; - } + if (!allowFrom) return fallbackAllowFrom; + const value = allowFrom[provider]; + return Array.isArray(value) ? value : fallbackAllowFrom; } function isApprovedElevatedSender(params: { provider: string; ctx: MsgContext; allowFrom?: AgentElevatedAllowFromConfig; - discordFallback?: Array; + fallbackAllowFrom?: Array; }): boolean { const rawAllow = resolveElevatedAllowList( params.allowFrom, params.provider, - params.discordFallback, + params.fallbackAllowFrom, ); if (!rawAllow || rawAllow.length === 0) return false; @@ -248,23 +242,24 @@ function resolveElevatedPermissions(params: { return { enabled, allowed: false, failures }; } - const discordFallback = - params.provider === "discord" - ? params.cfg.discord?.dm?.allowFrom - : undefined; + const normalizedProvider = normalizeProviderId(params.provider); + const dockFallbackAllowFrom = normalizedProvider + ? getProviderDock(normalizedProvider)?.elevated?.allowFromFallback?.({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }) + : undefined; + const fallbackAllowFrom = dockFallbackAllowFrom; const globalAllowed = isApprovedElevatedSender({ provider: params.provider, ctx: params.ctx, allowFrom: globalConfig?.allowFrom, - discordFallback, + fallbackAllowFrom, }); if (!globalAllowed) { failures.push({ gate: "allowFrom", - key: - params.provider === "discord" && discordFallback - ? "tools.elevated.allowFrom.discord (or discord.dm.allowFrom fallback)" - : `tools.elevated.allowFrom.${params.provider}`, + key: `tools.elevated.allowFrom.${params.provider}`, }); return { enabled, allowed: false, failures }; } @@ -274,6 +269,7 @@ function resolveElevatedPermissions(params: { provider: params.provider, ctx: params.ctx, allowFrom: agentConfig.allowFrom, + fallbackAllowFrom, }) : true; if (!agentAllowed) { @@ -605,7 +601,8 @@ export async function getReplyFromConfig( agentCfg?.blockStreamingBreak === "message_end" ? "message_end" : "text_end"; - const blockStreamingEnabled = resolvedBlockStreaming === "on"; + const blockStreamingEnabled = + resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true; const blockReplyChunking = blockStreamingEnabled ? resolveBlockStreamingChunking( cfg, @@ -782,8 +779,13 @@ export async function getReplyFromConfig( : undefined; const isEmptyConfig = Object.keys(cfg).length === 0; + const skipWhenConfigEmpty = command.providerId + ? Boolean( + getProviderDock(command.providerId)?.commands?.skipWhenConfigEmpty, + ) + : false; if ( - command.isWhatsAppProvider && + skipWhenConfigEmpty && isEmptyConfig && command.from && command.to && @@ -854,6 +856,7 @@ export async function getReplyFromConfig( ); const groupIntro = shouldInjectGroupIntro ? buildGroupIntro({ + cfg, sessionCtx, sessionEntry, defaultActivation, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 701d75b8e..45a54543b 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -12,6 +12,7 @@ import { runEmbeddedPiAgent, } from "../../agents/pi-embedded.js"; import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js"; +import type { ClawdbotConfig } from "../../config/config.js"; import { loadSessionStore, resolveSessionTranscriptPath, @@ -26,6 +27,9 @@ import { registerAgentRunContext, } from "../../infra/agent-events.js"; import { isAudioFileName } from "../../media/mime.js"; +import { getProviderDock } from "../../providers/dock.js"; +import type { ProviderThreadingToolContext } from "../../providers/plugins/types.js"; +import { normalizeProviderId } from "../../providers/registry.js"; import { defaultRuntime } from "../../runtime.js"; import { estimateUsageCost, @@ -70,47 +74,32 @@ const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000; /** - * Build Slack-specific threading context for tool auto-injection. - * Returns undefined values for non-Slack providers. + * Build provider-specific threading context for tool auto-injection. */ -function buildSlackThreadingContext(params: { +function buildThreadingToolContext(params: { sessionCtx: TemplateContext; - config: { slack?: { replyToMode?: "off" | "first" | "all" } } | undefined; + config: ClawdbotConfig | undefined; hasRepliedRef: { value: boolean } | undefined; -}): { - currentChannelId: string | undefined; - currentThreadTs: string | undefined; - replyToMode: "off" | "first" | "all" | undefined; - hasRepliedRef: { value: boolean } | undefined; -} { +}): ProviderThreadingToolContext { const { sessionCtx, config, hasRepliedRef } = params; - const isSlack = sessionCtx.Provider?.toLowerCase() === "slack"; - if (!isSlack) { - return { - currentChannelId: undefined, - currentThreadTs: undefined, - replyToMode: undefined, - hasRepliedRef: undefined, - }; - } - - // If we're already inside a thread, never jump replies out of it (even in - // replyToMode="off"/"first"). This keeps tool calls consistent with the - // auto-reply path. - const configuredReplyToMode = config?.slack?.replyToMode ?? "off"; - const effectiveReplyToMode = sessionCtx.ThreadLabel - ? ("all" as const) - : configuredReplyToMode; - - return { - // Extract channel from "channel:C123" format - currentChannelId: sessionCtx.To?.startsWith("channel:") - ? sessionCtx.To.slice("channel:".length) - : undefined, - currentThreadTs: sessionCtx.ReplyToId, - replyToMode: effectiveReplyToMode, - hasRepliedRef, - }; + if (!config) return {}; + const provider = normalizeProviderId(sessionCtx.Provider); + if (!provider) return {}; + const dock = getProviderDock(provider); + if (!dock?.threading?.buildToolContext) return {}; + return ( + dock.threading.buildToolContext({ + cfg: config, + accountId: sessionCtx.AccountId, + context: { + Provider: sessionCtx.Provider, + To: sessionCtx.To, + ReplyToId: sessionCtx.ReplyToId, + ThreadLabel: sessionCtx.ThreadLabel, + }, + hasRepliedRef, + }) ?? {} + ); } const isBunFetchSocketError = (message?: string) => @@ -282,6 +271,7 @@ export async function runReplyAgent(params: { const replyToMode = resolveReplyToMode( followupRun.run.config, replyToChannel, + sessionCtx.AccountId, ); const applyReplyToMode = createReplyToModeFilterForChannel( replyToMode, @@ -437,8 +427,8 @@ export async function runReplyAgent(params: { messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, agentAccountId: sessionCtx.AccountId, - // Slack threading context for tool auto-injection - ...buildSlackThreadingContext({ + // Provider threading context for tool auto-injection + ...buildThreadingToolContext({ sessionCtx, config: followupRun.run.config, hasRepliedRef: opts?.hasRepliedRef, diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 3a0e1801e..1a89a1b2a 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -1,29 +1,17 @@ import type { ClawdbotConfig } from "../../config/config.js"; +import type { BlockStreamingCoalesceConfig } from "../../config/types.js"; +import { getProviderDock } from "../../providers/dock.js"; +import { normalizeProviderId, PROVIDER_IDS } from "../../providers/registry.js"; import { normalizeAccountId } from "../../routing/session-key.js"; +import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js"; import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000; -const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200; -const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800; -const PROVIDER_COALESCE_DEFAULTS: Partial< - Record -> = { - signal: { minChars: 1500, idleMs: 1000 }, - slack: { minChars: 1500, idleMs: 1000 }, - discord: { minChars: 1500, idleMs: 1000 }, -}; - const BLOCK_CHUNK_PROVIDERS = new Set([ - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "webchat", - "msteams", + ...PROVIDER_IDS, + INTERNAL_MESSAGE_PROVIDER, ]); function normalizeChunkProvider( @@ -36,6 +24,29 @@ function normalizeChunkProvider( : undefined; } +type ProviderBlockStreamingConfig = { + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + accounts?: Record< + string, + { blockStreamingCoalesce?: BlockStreamingCoalesceConfig } + >; +}; + +function resolveProviderBlockStreamingCoalesce(params: { + cfg: ClawdbotConfig | undefined; + providerKey?: TextChunkProvider; + accountId?: string | null; +}): BlockStreamingCoalesceConfig | undefined { + const { cfg, providerKey, accountId } = params; + if (!cfg || !providerKey) return undefined; + const providerCfg = (cfg as Record)[providerKey]; + if (!providerCfg || typeof providerCfg !== "object") return undefined; + const normalizedAccountId = normalizeAccountId(accountId); + const typed = providerCfg as ProviderBlockStreamingConfig; + const accountCfg = typed.accounts?.[normalizedAccountId]; + return accountCfg?.blockStreamingCoalesce ?? typed.blockStreamingCoalesce; +} + export type BlockStreamingCoalescing = { minChars: number; maxChars: number; @@ -53,7 +64,13 @@ export function resolveBlockStreamingChunking( breakPreference: "paragraph" | "newline" | "sentence"; } { const providerKey = normalizeChunkProvider(provider); - const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId); + const providerId = providerKey ? normalizeProviderId(providerKey) : null; + const providerChunkLimit = providerId + ? getProviderDock(providerId)?.outbound?.textChunkLimit + : undefined; + const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, { + fallbackLimit: providerChunkLimit, + }); const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk; const maxRequested = Math.max( 1, @@ -74,39 +91,6 @@ export function resolveBlockStreamingChunking( return { minChars, maxChars, breakPreference }; } -export function resolveTelegramDraftStreamingChunking( - cfg: ClawdbotConfig | undefined, - accountId?: string | null, -): { - minChars: number; - maxChars: number; - breakPreference: "paragraph" | "newline" | "sentence"; -} { - const providerKey: TextChunkProvider = "telegram"; - const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId); - const normalizedAccountId = normalizeAccountId(accountId); - const draftCfg = - cfg?.telegram?.accounts?.[normalizedAccountId]?.draftChunk ?? - cfg?.telegram?.draftChunk; - - const maxRequested = Math.max( - 1, - Math.floor(draftCfg?.maxChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MAX), - ); - const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); - const minRequested = Math.max( - 1, - Math.floor(draftCfg?.minChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MIN), - ); - const minChars = Math.min(minRequested, maxChars); - const breakPreference = - draftCfg?.breakPreference === "newline" || - draftCfg?.breakPreference === "sentence" - ? draftCfg.breakPreference - : "paragraph"; - return { minChars, maxChars, breakPreference }; -} - export function resolveBlockStreamingCoalescing( cfg: ClawdbotConfig | undefined, provider?: string, @@ -118,54 +102,21 @@ export function resolveBlockStreamingCoalescing( }, ): BlockStreamingCoalescing | undefined { const providerKey = normalizeChunkProvider(provider); - const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId); - const normalizedAccountId = normalizeAccountId(accountId); - const providerDefaults = providerKey - ? PROVIDER_COALESCE_DEFAULTS[providerKey] + const providerId = providerKey ? normalizeProviderId(providerKey) : null; + const providerChunkLimit = providerId + ? getProviderDock(providerId)?.outbound?.textChunkLimit : undefined; - const providerCfg = (() => { - if (!cfg || !providerKey) return undefined; - if (providerKey === "whatsapp") { - return ( - cfg.whatsapp?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ?? - cfg.whatsapp?.blockStreamingCoalesce - ); - } - if (providerKey === "telegram") { - return ( - cfg.telegram?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ?? - cfg.telegram?.blockStreamingCoalesce - ); - } - if (providerKey === "discord") { - return ( - cfg.discord?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ?? - cfg.discord?.blockStreamingCoalesce - ); - } - if (providerKey === "slack") { - return ( - cfg.slack?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ?? - cfg.slack?.blockStreamingCoalesce - ); - } - if (providerKey === "signal") { - return ( - cfg.signal?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ?? - cfg.signal?.blockStreamingCoalesce - ); - } - if (providerKey === "imessage") { - return ( - cfg.imessage?.accounts?.[normalizedAccountId]?.blockStreamingCoalesce ?? - cfg.imessage?.blockStreamingCoalesce - ); - } - if (providerKey === "msteams") { - return cfg.msteams?.blockStreamingCoalesce; - } - return undefined; - })(); + const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, { + fallbackLimit: providerChunkLimit, + }); + const providerDefaults = providerId + ? getProviderDock(providerId)?.streaming?.blockStreamingCoalesceDefaults + : undefined; + const providerCfg = resolveProviderBlockStreamingCoalesce({ + cfg, + providerKey, + accountId, + }); const coalesceCfg = providerCfg ?? cfg?.agents?.defaults?.blockStreamingCoalesce; const minRequested = Math.max( diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index d80cf9614..b55cf1fd1 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -54,9 +54,9 @@ import { triggerClawdbotRestart, } from "../../infra/restart.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; +import type { ProviderId } from "../../providers/plugins/types.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; -import { normalizeE164 } from "../../utils.js"; import { resolveCommandAuthorization } from "../command-auth.js"; import { normalizeCommandBody, @@ -108,10 +108,10 @@ function resolveSessionEntryForKey( export type CommandContext = { surface: string; provider: string; - isWhatsAppProvider: boolean; + providerId?: ProviderId; ownerList: string[]; isAuthorizedSender: boolean; - senderE164?: string; + senderId?: string; abortKey?: string; rawBodyNormalized: string; commandBodyNormalized: string; @@ -155,7 +155,7 @@ export async function buildStatusReply(params: { } = params; if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /status from unauthorized sender: ${command.senderId || ""}`, ); return undefined; } @@ -359,10 +359,10 @@ export function buildCommandContext(params: { return { surface, provider, - isWhatsAppProvider: auth.isWhatsAppProvider, + providerId: auth.providerId, ownerList: auth.ownerList, isAuthorizedSender: auth.isAuthorizedSender, - senderE164: auth.senderE164, + senderId: auth.senderId, abortKey, rawBodyNormalized, commandBodyNormalized, @@ -448,7 +448,7 @@ export async function handleCommands(params: { command.commandBodyNormalized === "/new"; if (resetRequested && !command.isAuthorizedSender) { logVerbose( - `Ignoring /reset from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /reset from unauthorized sender: ${command.senderId || ""}`, ); return { shouldContinue: false }; } @@ -472,22 +472,9 @@ export async function handleCommands(params: { reply: { text: "⚙️ Group activation only applies to group chats." }, }; } - const activationOwnerList = command.ownerList; - const activationSenderE164 = command.senderE164 - ? normalizeE164(command.senderE164) - : ""; - const isActivationOwner = - !command.isWhatsAppProvider || activationOwnerList.length === 0 - ? command.isAuthorizedSender - : Boolean(activationSenderE164) && - activationOwnerList.includes(activationSenderE164); - - if ( - !command.isAuthorizedSender || - (command.isWhatsAppProvider && !isActivationOwner) - ) { + if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /activation from unauthorized sender in group: ${command.senderE164 || ""}`, + `Ignoring /activation from unauthorized sender in group: ${command.senderId || ""}`, ); return { shouldContinue: false }; } @@ -515,7 +502,7 @@ export async function handleCommands(params: { if (allowTextCommands && sendPolicyCommand.hasCommand) { if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /send from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /send from unauthorized sender: ${command.senderId || ""}`, ); return { shouldContinue: false }; } @@ -552,7 +539,7 @@ export async function handleCommands(params: { if (allowTextCommands && command.commandBodyNormalized === "/restart") { if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /restart from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /restart from unauthorized sender: ${command.senderId || ""}`, ); return { shouldContinue: false }; } @@ -598,7 +585,7 @@ export async function handleCommands(params: { if (allowTextCommands && helpRequested) { if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /help from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /help from unauthorized sender: ${command.senderId || ""}`, ); return { shouldContinue: false }; } @@ -609,7 +596,7 @@ export async function handleCommands(params: { if (allowTextCommands && commandsRequested) { if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /commands from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /commands from unauthorized sender: ${command.senderId || ""}`, ); return { shouldContinue: false }; } @@ -673,7 +660,7 @@ export async function handleCommands(params: { if (configCommand) { if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /config from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /config from unauthorized sender: ${command.senderId || ""}`, ); return { shouldContinue: false }; } @@ -805,7 +792,7 @@ export async function handleCommands(params: { if (debugCommand) { if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /debug from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /debug from unauthorized sender: ${command.senderId || ""}`, ); return { shouldContinue: false }; } @@ -832,13 +819,11 @@ export async function handleCommands(params: { reply: { text: "⚙️ Debug overrides: (none)" }, }; } - const effectiveConfig = cfg ?? {}; const json = JSON.stringify(overrides, null, 2); - const effectiveJson = JSON.stringify(effectiveConfig, null, 2); return { shouldContinue: false, reply: { - text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\`\n⚙️ Effective config (with overrides):\n\`\`\`json\n${effectiveJson}\n\`\`\``, + text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``, }, }; } @@ -895,7 +880,7 @@ export async function handleCommands(params: { if (allowTextCommands && stopRequested) { if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /stop from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /stop from unauthorized sender: ${command.senderId || ""}`, ); return { shouldContinue: false }; } @@ -927,7 +912,7 @@ export async function handleCommands(params: { if (compactRequested) { if (!command.isAuthorizedSender) { logVerbose( - `Ignoring /compact from unauthorized sender: ${command.senderE164 || ""}`, + `Ignoring /compact from unauthorized sender: ${command.senderId || ""}`, ); return { shouldContinue: false }; } diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 99c3455bc..d5a240e32 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -198,7 +198,11 @@ export function createFollowupRunner(params: { (queued.run.messageProvider?.toLowerCase() as | OriginatingChannelType | undefined); - const replyToMode = resolveReplyToMode(queued.run.config, replyToChannel); + const replyToMode = resolveReplyToMode( + queued.run.config, + replyToChannel, + queued.originatingAccountId, + ); const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({ payloads: sanitizedPayloads, diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 2cf4dcd0c..16dbe01b3 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,181 +1,39 @@ import type { ClawdbotConfig } from "../../config/config.js"; -import { resolveProviderGroupRequireMention } from "../../config/group-policy.js"; import type { GroupKeyResolution, SessionEntry, } from "../../config/sessions.js"; -import { resolveSlackAccount } from "../../slack/accounts.js"; +import { getProviderDock } from "../../providers/dock.js"; +import { + getChatProviderMeta, + normalizeProviderId, +} from "../../providers/registry.js"; +import { isInternalMessageProvider } from "../../utils/message-provider.js"; import { normalizeGroupActivation } from "../group-activation.js"; import type { TemplateContext } from "../templating.js"; -function normalizeDiscordSlug(value?: string | null) { - if (!value) return ""; - let text = value.trim().toLowerCase(); - if (!text) return ""; - text = text.replace(/^[@#]+/, ""); - text = text.replace(/[\s_]+/g, "-"); - text = text.replace(/[^a-z0-9-]+/g, "-"); - text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); - return text; -} - -function normalizeSlackSlug(raw?: string | null) { - const trimmed = raw?.trim().toLowerCase() ?? ""; - if (!trimmed) return ""; - const dashed = trimmed.replace(/\s+/g, "-"); - const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-"); - return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); -} - -function parseTelegramGroupId(value?: string | null) { - const raw = value?.trim() ?? ""; - if (!raw) return { chatId: undefined, topicId: undefined }; - const parts = raw.split(":").filter(Boolean); - if ( - parts.length >= 3 && - parts[1] === "topic" && - /^-?\d+$/.test(parts[0]) && - /^\d+$/.test(parts[2]) - ) { - return { chatId: parts[0], topicId: parts[2] }; - } - if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) { - return { chatId: parts[0], topicId: parts[1] }; - } - return { chatId: raw, topicId: undefined }; -} - -function resolveTelegramRequireMention(params: { - cfg: ClawdbotConfig; - chatId?: string; - topicId?: string; -}): boolean | undefined { - const { cfg, chatId, topicId } = params; - if (!chatId) return undefined; - const groupConfig = cfg.telegram?.groups?.[chatId]; - const groupDefault = cfg.telegram?.groups?.["*"]; - const topicConfig = - topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined; - const defaultTopicConfig = - topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined; - if (typeof topicConfig?.requireMention === "boolean") { - return topicConfig.requireMention; - } - if (typeof defaultTopicConfig?.requireMention === "boolean") { - return defaultTopicConfig.requireMention; - } - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - if (typeof groupDefault?.requireMention === "boolean") { - return groupDefault.requireMention; - } - return undefined; -} - -function resolveDiscordGuildEntry( - guilds: NonNullable["guilds"], - groupSpace?: string, -) { - if (!guilds || Object.keys(guilds).length === 0) return null; - const space = groupSpace?.trim(); - if (space && guilds[space]) return guilds[space]; - const normalized = normalizeDiscordSlug(space); - if (normalized && guilds[normalized]) return guilds[normalized]; - if (normalized) { - const match = Object.values(guilds).find( - (entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized, - ); - if (match) return match; - } - return guilds["*"] ?? null; -} - export function resolveGroupRequireMention(params: { cfg: ClawdbotConfig; ctx: TemplateContext; groupResolution?: GroupKeyResolution; }): boolean { const { cfg, ctx, groupResolution } = params; - const provider = - groupResolution?.provider ?? ctx.Provider?.trim().toLowerCase(); + const rawProvider = groupResolution?.provider ?? ctx.Provider?.trim(); + const provider = normalizeProviderId(rawProvider); + if (!provider) return true; const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim(); const groupSpace = ctx.GroupSpace?.trim(); - if (provider === "telegram") { - const { chatId, topicId } = parseTelegramGroupId(groupId); - const requireMention = resolveTelegramRequireMention({ - cfg, - chatId, - topicId, - }); - if (typeof requireMention === "boolean") return requireMention; - return resolveProviderGroupRequireMention({ - cfg, - provider, - groupId: chatId ?? groupId, - }); - } - if (provider === "whatsapp" || provider === "imessage") { - return resolveProviderGroupRequireMention({ - cfg, - provider, - groupId, - }); - } - if (provider === "discord") { - const guildEntry = resolveDiscordGuildEntry( - cfg.discord?.guilds, - groupSpace, - ); - const channelEntries = guildEntry?.channels; - if (channelEntries && Object.keys(channelEntries).length > 0) { - const channelSlug = normalizeDiscordSlug(groupRoom); - const entry = - (groupId ? channelEntries[groupId] : undefined) ?? - (channelSlug - ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) - : undefined) ?? - (groupRoom - ? channelEntries[normalizeDiscordSlug(groupRoom)] - : undefined); - if (entry && typeof entry.requireMention === "boolean") { - return entry.requireMention; - } - } - if (typeof guildEntry?.requireMention === "boolean") { - return guildEntry.requireMention; - } - return true; - } - if (provider === "slack") { - const account = resolveSlackAccount({ cfg, accountId: ctx.AccountId }); - const channels = account.channels ?? {}; - const keys = Object.keys(channels); - if (keys.length === 0) return true; - const channelId = groupId?.trim(); - const channelName = groupRoom?.replace(/^#/, ""); - const normalizedName = normalizeSlackSlug(channelName); - const candidates = [ - channelId ?? "", - channelName ? `#${channelName}` : "", - channelName ?? "", - normalizedName, - ].filter(Boolean); - let matched: { requireMention?: boolean } | undefined; - for (const candidate of candidates) { - if (candidate && channels[candidate]) { - matched = channels[candidate]; - break; - } - } - const fallback = channels["*"]; - const resolved = matched ?? fallback; - if (typeof resolved?.requireMention === "boolean") { - return resolved.requireMention; - } - return true; - } + const requireMention = getProviderDock( + provider, + )?.groups?.resolveRequireMention?.({ + cfg, + groupId, + groupRoom, + groupSpace, + accountId: ctx.AccountId, + }); + if (typeof requireMention === "boolean") return requireMention; return true; } @@ -186,6 +44,7 @@ export function defaultGroupActivation( } export function buildGroupIntro(params: { + cfg: ClawdbotConfig; sessionCtx: TemplateContext; sessionEntry?: SessionEntry; defaultActivation: "always" | "mention"; @@ -196,14 +55,14 @@ export function buildGroupIntro(params: { params.defaultActivation; const subject = params.sessionCtx.GroupSubject?.trim(); const members = params.sessionCtx.GroupMembers?.trim(); - const provider = params.sessionCtx.Provider?.trim().toLowerCase(); + const rawProvider = params.sessionCtx.Provider?.trim(); + const providerKey = rawProvider?.toLowerCase() ?? ""; + const providerId = normalizeProviderId(rawProvider); const providerLabel = (() => { - if (!provider) return "chat"; - if (provider === "whatsapp") return "WhatsApp"; - if (provider === "telegram") return "Telegram"; - if (provider === "discord") return "Discord"; - if (provider === "webchat") return "WebChat"; - return `${provider.at(0)?.toUpperCase() ?? ""}${provider.slice(1)}`; + if (!providerKey) return "chat"; + if (isInternalMessageProvider(providerKey)) return "WebChat"; + if (providerId) return getChatProviderMeta(providerId).label; + return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`; })(); const subjectLine = subject ? `You are replying inside the ${providerLabel} group "${subject}".` @@ -213,10 +72,18 @@ export function buildGroupIntro(params: { activation === "always" ? "Activation: always-on (you receive every group message)." : "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included)."; - const whatsappIdsLine = - provider === "whatsapp" - ? "WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant)." - : undefined; + const groupId = params.sessionCtx.From?.replace(/^group:/, ""); + const groupRoom = params.sessionCtx.GroupRoom?.trim() ?? subject; + const groupSpace = params.sessionCtx.GroupSpace?.trim(); + const providerIdsLine = providerId + ? getProviderDock(providerId)?.groups?.resolveGroupIntroHint?.({ + cfg: params.cfg, + groupId, + groupRoom, + groupSpace, + accountId: params.sessionCtx.AccountId, + }) + : undefined; const silenceLine = activation === "always" ? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so Clawdbot stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.` @@ -231,7 +98,7 @@ export function buildGroupIntro(params: { subjectLine, membersLine, activationLine, - whatsappIdsLine, + providerIdsLine, silenceLine, cautionLine, lurkLine, diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index 63366001e..be36000b4 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -1,5 +1,7 @@ import { resolveAgentConfig } from "../../agents/agent-scope.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import { getProviderDock } from "../../providers/dock.js"; +import { normalizeProviderId } from "../../providers/registry.js"; import type { MsgContext } from "../templating.js"; function escapeRegExp(text: string): string { @@ -112,9 +114,14 @@ export function stripMentions( agentId?: string, ): string { let result = text; - const rawPatterns = resolveMentionPatterns(cfg, agentId); - const patterns = normalizeMentionPatterns(rawPatterns); - + const providerId = ctx.Provider ? normalizeProviderId(ctx.Provider) : null; + const providerMentions = providerId + ? getProviderDock(providerId)?.mentions + : undefined; + const patterns = normalizeMentionPatterns([ + ...resolveMentionPatterns(cfg, agentId), + ...(providerMentions?.stripPatterns?.({ ctx, cfg, agentId }) ?? []), + ]); for (const p of patterns) { try { const re = new RegExp(p, "gi"); @@ -123,16 +130,15 @@ export function stripMentions( // ignore invalid regex } } - const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); - if (selfE164) { - const esc = selfE164.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - result = result - .replace(new RegExp(esc, "gi"), " ") - .replace(new RegExp(`@${esc}`, "gi"), " "); + if (providerMentions?.stripMentions) { + result = providerMentions.stripMentions({ + text: result, + ctx, + cfg, + agentId, + }); } // Generic mention patterns like @123456789 or plain digits result = result.replace(/@[0-9+]{5,}/g, " "); - // Discord-style mentions (<@123> or <@!123>) - result = result.replace(/<@!?\d+>/g, " "); return result.replace(/\s+/g, " ").trim(); } diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index 7fabf083b..c7b144b75 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -568,14 +568,7 @@ export function scheduleFollowupDrain( } })(); } -function defaultQueueModeForProvider(provider?: string): QueueMode { - const normalized = provider?.trim().toLowerCase(); - if (normalized === "discord") return "collect"; - if (normalized === "webchat") return "collect"; - if (normalized === "whatsapp") return "collect"; - if (normalized === "telegram") return "collect"; - if (normalized === "imessage") return "collect"; - if (normalized === "signal") return "collect"; +function defaultQueueModeForProvider(_provider?: string): QueueMode { return "collect"; } export function resolveQueueSettings(params: { diff --git a/src/auto-reply/reply/reply-threading.ts b/src/auto-reply/reply/reply-threading.ts index bf7820af9..9c7061183 100644 --- a/src/auto-reply/reply/reply-threading.ts +++ b/src/auto-reply/reply/reply-threading.ts @@ -1,22 +1,22 @@ import type { ClawdbotConfig } from "../../config/config.js"; import type { ReplyToMode } from "../../config/types.js"; +import { getProviderDock } from "../../providers/dock.js"; +import { normalizeProviderId } from "../../providers/registry.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; export function resolveReplyToMode( cfg: ClawdbotConfig, channel?: OriginatingChannelType, + accountId?: string | null, ): ReplyToMode { - switch (channel) { - case "telegram": - return cfg.telegram?.replyToMode ?? "first"; - case "discord": - return cfg.discord?.replyToMode ?? "off"; - case "slack": - return cfg.slack?.replyToMode ?? "off"; - default: - return "all"; - } + const provider = normalizeProviderId(channel); + if (!provider) return "all"; + const resolved = getProviderDock(provider)?.threading?.resolveReplyToMode?.({ + cfg, + accountId, + }); + return resolved ?? "all"; } export function createReplyToModeFilter( @@ -43,7 +43,11 @@ export function createReplyToModeFilterForChannel( mode: ReplyToMode, channel?: OriginatingChannelType, ) { + const provider = normalizeProviderId(channel); + const allowTagsWhenOff = provider + ? Boolean(getProviderDock(provider)?.threading?.allowTagsWhenOff) + : false; return createReplyToModeFilter(mode, { - allowTagsWhenOff: channel === "slack", + allowTagsWhenOff, }); } diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 41fd9a8e2..a2d184037 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -236,11 +236,12 @@ describe("routeReply", () => { to: "conversation:19:abc@thread.tacv2", cfg, }); - expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({ - cfg, - to: "conversation:19:abc@thread.tacv2", - text: "hi", - mediaUrl: undefined, - }); + expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + to: "conversation:19:abc@thread.tacv2", + text: "hi", + }), + ); }); }); diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 1e3eaf8a8..0b3623e41 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -10,13 +10,8 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import type { ClawdbotConfig } from "../../config/config.js"; -import { sendMessageDiscord } from "../../discord/send.js"; -import { sendMessageIMessage } from "../../imessage/send.js"; -import { sendMessageMSTeams } from "../../msteams/send.js"; -import { sendMessageSignal } from "../../signal/send.js"; -import { sendMessageSlack } from "../../slack/send.js"; -import { sendMessageTelegram } from "../../telegram/send.js"; -import { sendMessageWhatsApp } from "../../web/outbound.js"; +import { normalizeProviderId } from "../../providers/registry.js"; +import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { normalizeReplyPayload } from "./normalize-reply.js"; @@ -93,118 +88,39 @@ export async function routeReply( return { ok: true }; } - const sendOne = async (params: { - text: string; - mediaUrl?: string; - }): Promise => { - if (abortSignal?.aborted) { - return { ok: false, error: "Reply routing aborted" }; - } - const { text, mediaUrl } = params; - switch (channel) { - case "telegram": { - const replyToMessageId = replyToId - ? Number.parseInt(replyToId, 10) - : undefined; - const resolvedReplyToMessageId = Number.isFinite(replyToMessageId) - ? replyToMessageId - : undefined; - const result = await sendMessageTelegram(to, text, { - mediaUrl, - messageThreadId: threadId, - replyToMessageId: resolvedReplyToMessageId, - accountId, - }); - return { ok: true, messageId: result.messageId }; - } + if (channel === INTERNAL_MESSAGE_PROVIDER) { + return { + ok: false, + error: "Webchat routing not supported for queued replies", + }; + } - case "slack": { - const result = await sendMessageSlack(to, text, { - mediaUrl, - threadTs: replyToId, - accountId, - }); - return { ok: true, messageId: result.messageId }; - } - - case "discord": { - const result = await sendMessageDiscord(to, text, { - mediaUrl, - replyTo: replyToId, - accountId, - }); - return { ok: true, messageId: result.messageId }; - } - - case "signal": { - const result = await sendMessageSignal(to, text, { - mediaUrl, - accountId, - }); - return { ok: true, messageId: result.messageId }; - } - - case "imessage": { - const result = await sendMessageIMessage(to, text, { - mediaUrl, - accountId, - }); - return { ok: true, messageId: result.messageId }; - } - - case "whatsapp": { - const result = await sendMessageWhatsApp(to, text, { - verbose: false, - mediaUrl, - accountId, - }); - return { ok: true, messageId: result.messageId }; - } - - case "webchat": { - return { - ok: false, - error: `Webchat routing not supported for queued replies`, - }; - } - - case "msteams": { - const result = await sendMessageMSTeams({ - cfg, - to, - text, - mediaUrl, - }); - return { ok: true, messageId: result.messageId }; - } - - default: { - const _exhaustive: never = channel; - return { ok: false, error: `Unknown channel: ${String(_exhaustive)}` }; - } - } - }; + const provider = normalizeProviderId(channel) ?? null; + if (!provider) { + return { ok: false, error: `Unknown channel: ${String(channel)}` }; + } + if (abortSignal?.aborted) { + return { ok: false, error: "Reply routing aborted" }; + } try { - if (abortSignal?.aborted) { - return { ok: false, error: "Reply routing aborted" }; - } - if (mediaUrls.length === 0) { - return await sendOne({ text }); - } - - let last: RouteReplyResult | undefined; - for (let i = 0; i < mediaUrls.length; i++) { - if (abortSignal?.aborted) { - return { ok: false, error: "Reply routing aborted" }; - } - const mediaUrl = mediaUrls[i]; - const caption = i === 0 ? text : ""; - last = await sendOne({ text: caption, mediaUrl }); - if (!last.ok) return last; - } - - return last ?? { ok: true }; + // Provider docking: this is an execution boundary (we're about to send). + // Keep the module cheap to import by loading outbound plumbing lazily. + const { deliverOutboundPayloads } = await import( + "../../infra/outbound/deliver.js" + ); + const results = await deliverOutboundPayloads({ + cfg, + provider, + to, + accountId: accountId ?? undefined, + payloads: [normalized], + replyToId: replyToId ?? null, + threadId: threadId ?? null, + abortSignal, + }); + const last = results.at(-1); + return { ok: true, messageId: last?.messageId }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { @@ -222,22 +138,10 @@ export async function routeReply( */ export function isRoutableChannel( channel: OriginatingChannelType | undefined, -): channel is - | "telegram" - | "slack" - | "discord" - | "signal" - | "imessage" - | "whatsapp" - | "msteams" { - if (!channel) return false; - return [ - "telegram", - "slack", - "discord", - "signal", - "imessage", - "whatsapp", - "msteams", - ].includes(channel); +): channel is Exclude< + OriginatingChannelType, + typeof INTERNAL_MESSAGE_PROVIDER +> { + if (!channel || channel === INTERNAL_MESSAGE_PROVIDER) return false; + return normalizeProviderId(channel) !== null; } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 07952727a..443139f78 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -23,6 +23,8 @@ import { type SessionScope, saveSessionStore, } from "../../config/sessions.js"; +import { getProviderDock } from "../../providers/dock.js"; +import { normalizeProviderId } from "../../providers/registry.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { resolveCommandAuthorization } from "../command-auth.js"; import type { MsgContext, TemplateContext } from "../templating.js"; @@ -236,7 +238,13 @@ export async function initSessionState(params: { const subject = ctx.GroupSubject?.trim(); const space = ctx.GroupSpace?.trim(); const explicitRoom = ctx.GroupRoom?.trim(); - const isRoomProvider = provider === "discord" || provider === "slack"; + const normalizedProvider = normalizeProviderId(provider); + const isRoomProvider = Boolean( + normalizedProvider && + getProviderDock(normalizedProvider)?.capabilities.chatTypes.includes( + "channel", + ), + ); const nextRoom = explicitRoom ?? (isRoomProvider && subject && subject.startsWith("#") diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 86182d856..a163ac5c0 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,13 +1,8 @@ +import type { ProviderId } from "../providers/plugins/types.js"; +import type { InternalMessageProvider } from "../utils/message-provider.js"; + /** Valid provider channels for message routing. */ -export type OriginatingChannelType = - | "telegram" - | "slack" - | "discord" - | "signal" - | "imessage" - | "whatsapp" - | "webchat" - | "msteams"; +export type OriginatingChannelType = ProviderId | InternalMessageProvider; export type MsgContext = { Body?: string; @@ -50,7 +45,7 @@ export type MsgContext = { SenderUsername?: string; SenderTag?: string; SenderE164?: string; - /** Provider label (whatsapp|telegram|discord|imessage|...). */ + /** Provider label (e.g. whatsapp, telegram). */ Provider?: string; /** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */ Surface?: string; diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index 62c2e6a17..c0b1fd72f 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -1,12 +1,18 @@ import type { Command } from "commander"; import type { CronJob, CronSchedule } from "../cron/types.js"; import { danger } from "../globals.js"; +import { listProviderPlugins } from "../providers/plugins/index.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; import type { GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; +const CRON_PROVIDER_OPTIONS = [ + "last", + ...listProviderPlugins().map((plugin) => plugin.id), +].join("|"); + async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { try { const res = (await callGatewayFromCli("cron.status", opts, {})) as { @@ -304,7 +310,7 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--provider ", - "Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)", + `Delivery provider (${CRON_PROVIDER_OPTIONS})`, "last", ) .option( @@ -571,7 +577,7 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--provider ", - "Delivery provider (last|whatsapp|telegram|discord|slack|signal|imessage)", + `Delivery provider (${CRON_PROVIDER_OPTIONS})`, ) .option( "--to ", diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 766af3027..175c383c6 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -50,6 +50,10 @@ import { getResolvedLoggerSettings } from "../logging.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { createDefaultDeps } from "./deps.js"; import { withProgress } from "./progress.js"; @@ -236,8 +240,8 @@ async function probeGatewayStatus(opts: { password: opts.password, method: "status", timeoutMs: opts.timeoutMs, - clientName: "cli", - mode: "cli", + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, ...(opts.configPath ? { configPath: opts.configPath } : {}), }), ); diff --git a/src/cli/deps.ts b/src/cli/deps.ts index aab4366c2..7a7b25d04 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,5 +1,7 @@ +import type { ClawdbotConfig } from "../config/config.js"; import { sendMessageDiscord } from "../discord/send.js"; import { sendMessageIMessage } from "../imessage/send.js"; +import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; import { sendMessageMSTeams } from "../msteams/send.js"; import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js"; import { sendMessageSignal } from "../signal/send.js"; @@ -28,4 +30,29 @@ export function createDefaultDeps(): CliDeps { }; } +// Provider docking: extend this mapping when adding new outbound send deps. +export function createOutboundSendDeps( + deps: CliDeps, + cfg: ClawdbotConfig, +): OutboundSendDeps { + return { + sendWhatsApp: deps.sendMessageWhatsApp, + sendTelegram: deps.sendMessageTelegram, + sendDiscord: deps.sendMessageDiscord, + sendSlack: deps.sendMessageSlack, + sendSignal: deps.sendMessageSignal, + sendIMessage: deps.sendMessageIMessage, + // Provider docking: MS Teams send requires full cfg (credentials), wrap to match OutboundSendDeps. + sendMSTeams: deps.sendMessageMSTeams + ? async (to, text, opts) => + await deps.sendMessageMSTeams({ + cfg, + to, + text, + mediaUrl: opts?.mediaUrl, + }) + : undefined, + }; +} + export { logWebSelfId }; diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 6bec837da..5a0451ed3 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -112,13 +112,13 @@ describe("gateway-cli coverage", () => { registerGatewayCli(program); await program.parseAsync( - ["gateway", "call", "health", "--params", '{"x":1}'], + ["gateway", "call", "health", "--params", '{"x":1}', "--json"], { from: "user" }, ); expect(callGateway).toHaveBeenCalledTimes(1); expect(runtimeLogs.join("\n")).toContain('"ok": true'); - }); + }, 15_000); it("registers gateway status and routes to gatewayStatusCommand", async () => { runtimeLogs.length = 0; @@ -133,7 +133,7 @@ describe("gateway-cli coverage", () => { await program.parseAsync(["gateway", "status", "--json"], { from: "user" }); expect(gatewayStatusCommand).toHaveBeenCalledTimes(1); - }); + }, 15_000); it("registers gateway discover and prints JSON", async () => { runtimeLogs.length = 0; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index ab93c80ef..c86a8bf7b 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -5,6 +5,10 @@ import path from "node:path"; import type { Command } from "commander"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { gatewayStatusCommand } from "../commands/gateway-status.js"; +import { + formatHealthProviderLines, + type HealthSummary, +} from "../commands/health.js"; import { handleReset } from "../commands/onboard-helpers.js"; import { CONFIG_PATH_CLAWDBOT, @@ -40,6 +44,10 @@ import { import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { resolveUserPath } from "../utils.js"; import { forceFreePortAndWait } from "./ports.js"; import { withProgress } from "./progress.js"; @@ -523,8 +531,8 @@ const callGatewayCli = async ( params, expectFinal: Boolean(opts.expectFinal), timeoutMs: Number(opts.timeout ?? 10_000), - clientName: "cli", - mode: "cli", + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, }), ); @@ -947,28 +955,12 @@ export function registerGatewayCli(program: Command) { durationMs != null ? ` (${durationMs}ms)` : "" }`, ); - if (obj.web && typeof obj.web === "object") { - const web = obj.web as Record; - const linked = web.linked === true; - defaultRuntime.log( - `Web: ${linked ? "linked" : "not linked"}${ - typeof web.authAgeMs === "number" && linked - ? ` (${Math.round(web.authAgeMs / 60_000)}m)` - : "" - }`, - ); - } - if (obj.telegram && typeof obj.telegram === "object") { - const tg = obj.telegram as Record; - defaultRuntime.log( - `Telegram: ${tg.configured === true ? "configured" : "not configured"}`, - ); - } - if (obj.discord && typeof obj.discord === "object") { - const dc = obj.discord as Record; - defaultRuntime.log( - `Discord: ${dc.configured === true ? "configured" : "not configured"}`, - ); + if (obj.providers && typeof obj.providers === "object") { + for (const line of formatHealthProviderLines( + obj as HealthSummary, + )) { + defaultRuntime.log(line); + } } } catch (err) { defaultRuntime.error(String(err)); diff --git a/src/cli/gateway-rpc.ts b/src/cli/gateway-rpc.ts index d67c6cb0c..6a8fb82bd 100644 --- a/src/cli/gateway-rpc.ts +++ b/src/cli/gateway-rpc.ts @@ -1,5 +1,9 @@ import type { Command } from "commander"; import { callGateway } from "../gateway/call.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { withProgress } from "./progress.js"; export type GatewayRpcOpts = { @@ -41,8 +45,8 @@ export async function callGatewayFromCli( params, expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal), timeoutMs: Number(opts.timeout ?? 10_000), - clientName: "cli", - mode: "cli", + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, }), ); } diff --git a/src/cli/nodes-cli.ts b/src/cli/nodes-cli.ts index 45b22d19c..949393212 100644 --- a/src/cli/nodes-cli.ts +++ b/src/cli/nodes-cli.ts @@ -4,6 +4,10 @@ import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { type CameraFacing, cameraTempPath, @@ -152,8 +156,8 @@ const callGatewayCli = async ( method, params, timeoutMs: Number(opts.timeout ?? 10_000), - clientName: "cli", - mode: "cli", + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, }), ); diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index 08bf99954..65721c188 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -3,40 +3,33 @@ import { describe, expect, it, vi } from "vitest"; const listProviderPairingRequests = vi.fn(); const approveProviderPairingCode = vi.fn(); +const notifyPairingApproved = vi.fn(); +const pairingIdLabels: Record = { + telegram: "telegramUserId", + discord: "discordUserId", +}; +const requirePairingAdapter = vi.fn((provider: string) => ({ + idLabel: pairingIdLabels[provider] ?? "userId", +})); +const listPairingProviders = vi.fn(() => ["telegram", "discord"]); +const resolvePairingProvider = vi.fn((raw: string) => raw); vi.mock("../pairing/pairing-store.js", () => ({ listProviderPairingRequests, approveProviderPairingCode, })); -vi.mock("../telegram/send.js", () => ({ - sendMessageTelegram: vi.fn(), -})); - -vi.mock("../discord/send.js", () => ({ - sendMessageDiscord: vi.fn(), -})); - -vi.mock("../slack/send.js", () => ({ - sendMessageSlack: vi.fn(), -})); - -vi.mock("../signal/send.js", () => ({ - sendMessageSignal: vi.fn(), -})); - -vi.mock("../imessage/send.js", () => ({ - sendMessageIMessage: vi.fn(), +vi.mock("../providers/plugins/pairing.js", () => ({ + listPairingProviders, + resolvePairingProvider, + notifyPairingApproved, + requirePairingAdapter, })); vi.mock("../config/config.js", () => ({ loadConfig: vi.fn().mockReturnValue({}), })); -vi.mock("../telegram/token.js", () => ({ - resolveTelegramToken: vi.fn().mockReturnValue({ token: "t" }), -})); - describe("pairing cli", () => { it("labels Telegram ids as telegramUserId", async () => { const { registerPairingCli } = await import("./pairing-cli.js"); diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index b6860e85b..671cf2490 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -1,78 +1,27 @@ import type { Command } from "commander"; import { loadConfig } from "../config/config.js"; -import { sendMessageDiscord } from "../discord/send.js"; -import { sendMessageIMessage } from "../imessage/send.js"; -import { sendMessageMSTeams } from "../msteams/send.js"; -import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js"; +import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; import { approveProviderPairingCode, listProviderPairingRequests, type PairingProvider, } from "../pairing/pairing-store.js"; -import { sendMessageSignal } from "../signal/send.js"; -import { sendMessageSlack } from "../slack/send.js"; -import { sendMessageTelegram } from "../telegram/send.js"; -import { resolveTelegramToken } from "../telegram/token.js"; +import { + listPairingProviders, + notifyPairingApproved, + resolvePairingProvider, +} from "../providers/plugins/pairing.js"; -const PROVIDERS: PairingProvider[] = [ - "telegram", - "signal", - "imessage", - "discord", - "slack", - "whatsapp", - "msteams", -]; +const PROVIDERS: PairingProvider[] = listPairingProviders(); function parseProvider(raw: unknown): PairingProvider { - const value = ( - typeof raw === "string" - ? raw - : typeof raw === "number" || typeof raw === "boolean" - ? String(raw) - : "" - ) - .trim() - .toLowerCase(); - if ((PROVIDERS as string[]).includes(value)) return value as PairingProvider; - throw new Error( - `Invalid provider: ${value || "(empty)"} (expected one of: ${PROVIDERS.join(", ")})`, - ); + return resolvePairingProvider(raw); } async function notifyApproved(provider: PairingProvider, id: string) { - const message = - "✅ Clawdbot access approved. Send a message to start chatting."; - if (provider === "telegram") { - const cfg = loadConfig(); - const { token } = resolveTelegramToken(cfg); - if (!token) throw new Error("telegram token not configured"); - await sendMessageTelegram(id, message, { token }); - return; - } - if (provider === "discord") { - await sendMessageDiscord(`user:${id}`, message); - return; - } - if (provider === "slack") { - await sendMessageSlack(`user:${id}`, message); - return; - } - if (provider === "signal") { - await sendMessageSignal(id, message); - return; - } - if (provider === "imessage") { - await sendMessageIMessage(id, message); - return; - } - if (provider === "msteams") { - const cfg = loadConfig(); - await sendMessageMSTeams({ cfg, to: id, text: message }); - return; - } - // WhatsApp: approval still works (store); notifying requires an active web session. + const cfg = loadConfig(); + await notifyPairingApproved({ providerId: provider, id, cfg }); } export function registerPairingCli(program: Command) { @@ -105,7 +54,7 @@ export function registerPairingCli(program: Command) { } for (const r of requests) { const meta = r.meta ? JSON.stringify(r.meta) : ""; - const idLabel = PROVIDER_ID_LABELS[provider]; + const idLabel = resolvePairingIdLabel(provider); console.log( `${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`, ); diff --git a/src/cli/program.ts b/src/cli/program.ts index a614d13cb..143169ca4 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -28,6 +28,8 @@ import { } from "../config/config.js"; import { danger, setVerbose } from "../globals.js"; import { autoMigrateLegacyState } from "../infra/state-migrations.js"; +import { listProviderPlugins } from "../providers/plugins/index.js"; +import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; @@ -67,6 +69,9 @@ function collectOption(value: string, previous: string[] = []): string[] { export function buildProgram() { const program = new Command(); const PROGRAM_VERSION = VERSION; + const providerOptions = listProviderPlugins().map((plugin) => plugin.id); + const messageProviderOptions = providerOptions.join("|"); + const agentProviderOptions = ["last", ...providerOptions].join("|"); program .name("clawdbot") @@ -591,10 +596,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/message", "docs.clawd.bot/message")}` const withMessageBase = (command: Command) => command - .option( - "--provider ", - "Provider: whatsapp|telegram|discord|slack|signal|imessage", - ) + .option("--provider ", `Provider: ${messageProviderOptions}`) .option("--account ", "Provider account id") .option("--json", "Output result as JSON", false) .option("--dry-run", "Print payload and skip sending", false) @@ -1061,7 +1063,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/message", "docs.clawd.bot/message")}` .option("--verbose ", "Persist agent verbose level for the session") .option( "--provider ", - "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)", + `Delivery provider: ${agentProviderOptions} (default: ${DEFAULT_CHAT_PROVIDER})`, ) .option( "--local", @@ -1220,7 +1222,7 @@ ${theme.muted("Docs:")} ${formatDocsLink( program .command("status") - .description("Show local status (gateway, agents, sessions, auth)") + .description("Show provider health and recent session recipients") .option("--json", "Output JSON instead of text", false) .option("--all", "Full diagnosis (read-only, pasteable)", false) .option("--usage", "Show provider usage/quota snapshots", false) diff --git a/src/cli/provider-auth.ts b/src/cli/provider-auth.ts index 27aec0c85..c89d1a537 100644 --- a/src/cli/provider-auth.ts +++ b/src/cli/provider-auth.ts @@ -1,8 +1,12 @@ import { loadConfig } from "../config/config.js"; import { setVerbose } from "../globals.js"; -import { loginWeb, logoutWeb } from "../provider-web.js"; +import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../providers/plugins/index.js"; +import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { resolveWhatsAppAccount } from "../web/accounts.js"; type ProviderAuthOptions = { provider?: string; @@ -10,44 +14,55 @@ type ProviderAuthOptions = { verbose?: boolean; }; -function normalizeProvider(raw?: string): "whatsapp" | "web" { - const value = String(raw ?? "whatsapp") - .trim() - .toLowerCase(); - if (value === "whatsapp" || value === "web") return value; - throw new Error(`Unsupported provider: ${value}`); -} - export async function runProviderLogin( opts: ProviderAuthOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const provider = normalizeProvider(opts.provider); + const providerInput = opts.provider ?? DEFAULT_CHAT_PROVIDER; + const providerId = normalizeProviderId(providerInput); + if (!providerId) { + throw new Error(`Unsupported provider: ${providerInput}`); + } + const plugin = getProviderPlugin(providerId); + if (!plugin?.auth?.login) { + throw new Error(`Provider ${providerId} does not support login`); + } // Auth-only flow: do not mutate provider config here. setVerbose(Boolean(opts.verbose)); - await loginWeb( - Boolean(opts.verbose), - provider, - undefined, + const cfg = loadConfig(); + const accountId = + opts.account?.trim() || resolveProviderDefaultAccountId({ plugin, cfg }); + await plugin.auth.login({ + cfg, + accountId, runtime, - opts.account, - ); + verbose: Boolean(opts.verbose), + providerInput, + }); } export async function runProviderLogout( opts: ProviderAuthOptions, runtime: RuntimeEnv = defaultRuntime, ) { - const _provider = normalizeProvider(opts.provider); + const providerInput = opts.provider ?? DEFAULT_CHAT_PROVIDER; + const providerId = normalizeProviderId(providerInput); + if (!providerId) { + throw new Error(`Unsupported provider: ${providerInput}`); + } + const plugin = getProviderPlugin(providerId); + if (!plugin?.gateway?.logoutAccount) { + throw new Error(`Provider ${providerId} does not support logout`); + } // Auth-only flow: resolve account + clear session state only. const cfg = loadConfig(); - const account = resolveWhatsAppAccount({ + const accountId = + opts.account?.trim() || resolveProviderDefaultAccountId({ plugin, cfg }); + const account = plugin.config.resolveAccount(cfg, accountId); + await plugin.gateway.logoutAccount({ cfg, - accountId: opts.account, - }); - await logoutWeb({ + accountId, + account, runtime, - authDir: account.authDir, - isLegacyAuthDir: account.isLegacyAuthDir, }); } diff --git a/src/cli/providers-cli.ts b/src/cli/providers-cli.ts index bd9a1f4c6..2d3a79862 100644 --- a/src/cli/providers-cli.ts +++ b/src/cli/providers-cli.ts @@ -169,9 +169,9 @@ export function registerProvidersCli(program: Command) { providers .command("logout") - .description("Log out of a provider session (WhatsApp Web only)") + .description("Log out of a provider session (if supported)") .option("--provider ", "Provider alias (default: whatsapp)") - .option("--account ", "WhatsApp account id (accountId)") + .option("--account ", "Account id (accountId)") .action(async (opts) => { try { await runProviderLogout( diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index d0c6857f5..eae524864 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -7,9 +7,14 @@ import { resolveStorePath, } from "../config/sessions.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; +import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js"; import { normalizeMainKey } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; -import { normalizeMessageProvider } from "../utils/message-provider.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + normalizeMessageProvider, +} from "../utils/message-provider.js"; import { agentCommand } from "./agent.js"; type AgentGatewayResult = { @@ -124,7 +129,8 @@ export async function agentViaGatewayCommand( sessionId: opts.sessionId, }); - const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp"; + const provider = + normalizeMessageProvider(opts.provider) ?? DEFAULT_CHAT_PROVIDER; const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey(); const response = await withProgress( @@ -151,8 +157,8 @@ export async function agentViaGatewayCommand( }, expectFinal: true, timeoutMs: gatewayTimeoutMs, - clientName: "cli", - mode: "cli", + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, }), ); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 77fb4d942..d7fc58897 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -33,7 +33,11 @@ import { type ThinkLevel, type VerboseLevel, } from "../auto-reply/thinking.js"; -import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; +import { + type CliDeps, + createDefaultDeps, + createOutboundSendDeps, +} from "../cli/deps.js"; import { type ClawdbotConfig, loadConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, @@ -58,15 +62,21 @@ import { normalizeOutboundPayloadsForJson, } from "../infra/outbound/payloads.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../providers/plugins/index.js"; +import type { ProviderOutboundTargetMode } from "../providers/plugins/types.js"; +import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; import { - normalizeMessageProvider, + isInternalMessageProvider, + resolveGatewayMessageProvider, resolveMessageProvider, } from "../utils/message-provider.js"; -import { normalizeE164 } from "../utils.js"; /** Image content block for Claude API multimodal messages. */ type ImageContent = { @@ -91,6 +101,7 @@ type AgentCommandOpts = { /** Message provider context (webchat|voicewake|whatsapp|...). */ messageProvider?: string; provider?: string; // delivery provider (whatsapp|telegram|...) + deliveryTargetMode?: ProviderOutboundTargetMode; bestEffortDeliver?: boolean; abortSignal?: AbortSignal; lane?: string; @@ -204,10 +215,6 @@ export async function agentCommand( }); const workspaceDir = workspace.dir; - const allowFrom = (cfg.whatsapp?.allowFrom ?? []) - .map((val) => normalizeE164(val)) - .filter((val) => val.length > 1); - const thinkOverride = normalizeThinkLevel(opts.thinking); const thinkOnce = normalizeThinkLevel(opts.thinkingOnce); if (opts.thinking && !thinkOverride) { @@ -570,7 +577,13 @@ export async function agentCommand( const deliver = opts.deliver === true; const bestEffortDeliver = opts.bestEffortDeliver === true; const deliveryProvider = - normalizeMessageProvider(opts.provider) ?? "whatsapp"; + resolveGatewayMessageProvider(opts.provider) ?? DEFAULT_CHAT_PROVIDER; + // Provider docking: delivery providers are resolved via plugin registry. + const deliveryPlugin = !isInternalMessageProvider(deliveryProvider) + ? getProviderPlugin( + normalizeProviderId(deliveryProvider) ?? deliveryProvider, + ) + : undefined; const logDeliveryError = (err: unknown) => { const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`; @@ -579,20 +592,19 @@ export async function agentCommand( }; const isDeliveryProviderKnown = - deliveryProvider === "whatsapp" || - deliveryProvider === "telegram" || - deliveryProvider === "discord" || - deliveryProvider === "slack" || - deliveryProvider === "signal" || - deliveryProvider === "imessage" || - deliveryProvider === "webchat"; + isInternalMessageProvider(deliveryProvider) || Boolean(deliveryPlugin); + const targetMode: ProviderOutboundTargetMode = + opts.deliveryTargetMode ?? (opts.to ? "explicit" : "implicit"); const resolvedTarget = - deliver && isDeliveryProviderKnown + deliver && isDeliveryProviderKnown && deliveryProvider ? resolveOutboundTarget({ provider: deliveryProvider, to: opts.to, - allowFrom, + cfg, + accountId: + targetMode === "implicit" ? sessionEntry?.lastAccountId : undefined, + mode: targetMode, }) : null; const deliveryTarget = resolvedTarget?.ok ? resolvedTarget.to : undefined; @@ -643,12 +655,8 @@ export async function agentCommand( } if ( deliver && - (deliveryProvider === "whatsapp" || - deliveryProvider === "telegram" || - deliveryProvider === "discord" || - deliveryProvider === "slack" || - deliveryProvider === "signal" || - deliveryProvider === "imessage") + deliveryProvider && + !isInternalMessageProvider(deliveryProvider) ) { if (deliveryTarget) { await deliverOutboundPayloads({ @@ -659,14 +667,7 @@ export async function agentCommand( bestEffort: bestEffortDeliver, onError: (err) => logDeliveryError(err), onPayload: logPayload, - deps: { - sendWhatsApp: deps.sendMessageWhatsApp, - sendTelegram: deps.sendMessageTelegram, - sendDiscord: deps.sendMessageDiscord, - sendSlack: deps.sendMessageSlack, - sendSignal: deps.sendMessageSignal, - sendIMessage: deps.sendMessageIMessage, - }, + deps: createOutboundSendDeps(deps, cfg), }); } } diff --git a/src/commands/agents.ts b/src/commands/agents.ts index ae00cd130..72fa35e53 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -14,17 +14,11 @@ import { writeConfigFile, } from "../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; +import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js"; import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "../discord/accounts.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "../imessage/accounts.js"; -import { resolveMSTeamsCredentials } from "../msteams/token.js"; + getProviderPlugin, + listProviderPlugins, +} from "../providers/plugins/index.js"; import { type ChatProviderId, getChatProviderMeta, @@ -37,28 +31,7 @@ import { } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "../signal/accounts.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, -} from "../slack/accounts.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "../telegram/accounts.js"; import { resolveUserPath } from "../utils.js"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAuthDir, -} from "../web/accounts.js"; -import { webAuthExists } from "../web/session.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { WizardCancelledError } from "../wizard/prompts.js"; import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js"; @@ -486,97 +459,46 @@ async function buildProviderStatusIndex( ): Promise> { const map = new Map(); - for (const accountId of listWhatsAppAccountIds(cfg)) { - const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); - const linked = await webAuthExists(authDir); - const enabled = - cfg.whatsapp?.accounts?.[accountId]?.enabled ?? cfg.web?.enabled ?? true; - const hasConfig = Boolean(cfg.whatsapp); - map.set(providerAccountKey("whatsapp", accountId), { - provider: "whatsapp", - accountId, - name: cfg.whatsapp?.accounts?.[accountId]?.name, - state: linked ? "linked" : "not linked", - enabled, - configured: linked || hasConfig, - }); - } - - for (const accountId of listTelegramAccountIds(cfg)) { - const account = resolveTelegramAccount({ cfg, accountId }); - const configured = Boolean(account.token); - map.set(providerAccountKey("telegram", accountId), { - provider: "telegram", - accountId, - name: account.name, - state: configured ? "configured" : "not configured", - enabled: account.enabled, - configured, - }); - } - - for (const accountId of listDiscordAccountIds(cfg)) { - const account = resolveDiscordAccount({ cfg, accountId }); - const configured = Boolean(account.token); - map.set(providerAccountKey("discord", accountId), { - provider: "discord", - accountId, - name: account.name, - state: configured ? "configured" : "not configured", - enabled: account.enabled, - configured, - }); - } - - for (const accountId of listSlackAccountIds(cfg)) { - const account = resolveSlackAccount({ cfg, accountId }); - const configured = Boolean(account.botToken && account.appToken); - map.set(providerAccountKey("slack", accountId), { - provider: "slack", - accountId, - name: account.name, - state: configured ? "configured" : "not configured", - enabled: account.enabled, - configured, - }); - } - - for (const accountId of listSignalAccountIds(cfg)) { - const account = resolveSignalAccount({ cfg, accountId }); - map.set(providerAccountKey("signal", accountId), { - provider: "signal", - accountId, - name: account.name, - state: account.configured ? "configured" : "not configured", - enabled: account.enabled, - configured: account.configured, - }); - } - - for (const accountId of listIMessageAccountIds(cfg)) { - const account = resolveIMessageAccount({ cfg, accountId }); - map.set(providerAccountKey("imessage", accountId), { - provider: "imessage", - accountId, - name: account.name, - state: account.enabled ? "enabled" : "disabled", - enabled: account.enabled, - configured: Boolean(cfg.imessage), - }); - } - - { - const accountId = DEFAULT_ACCOUNT_ID; - const hasCreds = Boolean(resolveMSTeamsCredentials(cfg.msteams)); - const hasConfig = Boolean(cfg.msteams); - const enabled = cfg.msteams?.enabled !== false; - map.set(providerAccountKey("msteams", accountId), { - provider: "msteams", - accountId, - state: hasCreds ? "configured" : "not configured", - enabled, - configured: hasCreds || hasConfig, - }); + for (const plugin of listProviderPlugins()) { + const accountIds = plugin.config.listAccountIds(cfg); + for (const accountId of accountIds) { + const account = plugin.config.resolveAccount(cfg, accountId); + const snapshot = plugin.config.describeAccount?.(account, cfg); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : typeof snapshot?.enabled === "boolean" + ? snapshot.enabled + : (account as { enabled?: boolean }).enabled; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : snapshot?.configured; + const resolvedEnabled = typeof enabled === "boolean" ? enabled : true; + const resolvedConfigured = + typeof configured === "boolean" ? configured : true; + const state = + plugin.status?.resolveAccountState?.({ + account, + cfg, + configured: resolvedConfigured, + enabled: resolvedEnabled, + }) ?? + (typeof snapshot?.linked === "boolean" + ? snapshot.linked + ? "linked" + : "not linked" + : resolvedConfigured + ? "configured" + : "not configured"); + const name = snapshot?.name ?? (account as { name?: string }).name; + map.set(providerAccountKey(plugin.id, accountId), { + provider: plugin.id, + accountId, + name, + state, + enabled, + configured, + }); + } } return map; @@ -586,33 +508,20 @@ function resolveDefaultAccountId( cfg: ClawdbotConfig, provider: ChatProviderId, ): string { - switch (provider) { - case "whatsapp": - return resolveDefaultWhatsAppAccountId(cfg) || DEFAULT_ACCOUNT_ID; - case "telegram": - return resolveDefaultTelegramAccountId(cfg) || DEFAULT_ACCOUNT_ID; - case "discord": - return resolveDefaultDiscordAccountId(cfg) || DEFAULT_ACCOUNT_ID; - case "slack": - return resolveDefaultSlackAccountId(cfg) || DEFAULT_ACCOUNT_ID; - case "signal": - return resolveDefaultSignalAccountId(cfg) || DEFAULT_ACCOUNT_ID; - case "imessage": - return resolveDefaultIMessageAccountId(cfg) || DEFAULT_ACCOUNT_ID; - case "msteams": - return DEFAULT_ACCOUNT_ID; - } + const plugin = getProviderPlugin(provider); + if (!plugin) return DEFAULT_ACCOUNT_ID; + return resolveProviderDefaultAccountId({ plugin, cfg }); } function shouldShowProviderEntry( entry: ProviderAccountStatus, cfg: ClawdbotConfig, ): boolean { - if (entry.provider === "whatsapp") { - return entry.state === "linked" || Boolean(cfg.whatsapp); - } - if (entry.provider === "imessage") { - return Boolean(cfg.imessage); + const plugin = getProviderPlugin(entry.provider); + if (!plugin) return Boolean(entry.configured); + if (plugin.meta.showConfigured === false) { + const providerConfig = (cfg as Record)[plugin.id]; + return Boolean(entry.configured) || Boolean(providerConfig); } return Boolean(entry.configured); } @@ -777,9 +686,11 @@ function buildProviderBindings(params: { const accountId = params.accountIds?.[provider]?.trim(); if (accountId) { match.accountId = accountId; - } else if (provider === "whatsapp") { - const defaultId = resolveDefaultWhatsAppAccountId(params.config); - match.accountId = defaultId || DEFAULT_ACCOUNT_ID; + } else { + const plugin = getProviderPlugin(provider); + if (plugin?.meta.forceAccountBinding) { + match.accountId = resolveDefaultAccountId(params.config, provider); + } } bindings.push({ agentId, match }); } @@ -809,9 +720,11 @@ function parseBindingSpecs(params: { errors.push(`Invalid binding "${trimmed}" (empty account id).`); continue; } - if (!accountId && provider === "whatsapp") { - accountId = resolveDefaultWhatsAppAccountId(params.config); - if (!accountId) accountId = DEFAULT_ACCOUNT_ID; + if (!accountId) { + const plugin = getProviderPlugin(provider); + if (plugin?.meta.forceAccountBinding) { + accountId = resolveDefaultAccountId(params.config, provider); + } } const match: AgentBinding["match"] = { provider }; if (accountId) match.accountId = accountId; diff --git a/src/commands/doctor-sandbox.ts b/src/commands/doctor-sandbox.ts index 703a5af2e..dee2bbd81 100644 --- a/src/commands/doctor-sandbox.ts +++ b/src/commands/doctor-sandbox.ts @@ -288,9 +288,12 @@ export function noteSandboxScopeWarnings(cfg: ClawdbotConfig) { if (overrides.length === 0) continue; warnings.push( - `- agents.list (id "${agentId}") sandbox ${overrides.join( - "/", - )} overrides ignored\n scope resolves to "shared".`, + [ + `- agents.list (id "${agentId}") sandbox ${overrides.join( + "/", + )} overrides ignored.`, + ` scope resolves to "shared".`, + ].join("\n"), ); } diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index d1c878a50..84d22f892 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -1,29 +1,25 @@ import type { ClawdbotConfig } from "../config/config.js"; import { readProviderAllowFromStore } from "../pairing/pairing-store.js"; -import { readTelegramAllowFromStore } from "../telegram/pairing-store.js"; -import { resolveTelegramToken } from "../telegram/token.js"; +import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js"; +import { listProviderPlugins } from "../providers/plugins/index.js"; +import type { ProviderId } from "../providers/plugins/types.js"; import { note } from "../terminal/note.js"; -import { normalizeE164 } from "../utils.js"; export async function noteSecurityWarnings(cfg: ClawdbotConfig) { const warnings: string[] = []; const warnDmPolicy = async (params: { label: string; - provider: - | "telegram" - | "signal" - | "imessage" - | "discord" - | "slack" - | "whatsapp"; + provider: ProviderId; dmPolicy: string; allowFrom?: Array | null; + policyPath?: string; allowFromPath: string; approveHint: string; normalizeEntry?: (raw: string) => string; }) => { const dmPolicy = params.dmPolicy; + const policyPath = params.policyPath ?? `${params.allowFromPath}policy`; const configAllowFrom = (params.allowFrom ?? []).map((v) => String(v).trim(), ); @@ -45,7 +41,6 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { ).length; if (dmPolicy === "open") { - const policyPath = `${params.allowFromPath}policy`; const allowFromPath = `${params.allowFromPath}allowFrom`; warnings.push( `- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`, @@ -59,7 +54,6 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { } if (dmPolicy === "disabled") { - const policyPath = `${params.allowFromPath}policy`; warnings.push( `- ${params.label} DMs: disabled (${policyPath}="disabled").`, ); @@ -67,7 +61,6 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { } if (allowCount === 0) { - const policyPath = `${params.allowFromPath}policy`; warnings.push( `- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, ); @@ -75,121 +68,50 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { } }; - const telegramConfigured = Boolean(cfg.telegram); - const { token: telegramToken } = resolveTelegramToken(cfg); - if (telegramConfigured && telegramToken.trim()) { - const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing"; - const configAllowFrom = (cfg.telegram?.allowFrom ?? []).map((v) => - String(v).trim(), - ); - const hasWildcard = configAllowFrom.includes("*"); - const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); - const allowCount = Array.from( - new Set([ - ...configAllowFrom - .filter((v) => v !== "*") - .map((v) => v.replace(/^(telegram|tg):/i, "")) - .filter(Boolean), - ...storeAllowFrom.filter((v) => v !== "*"), - ]), - ).length; - - if (dmPolicy === "open") { - warnings.push( - `- Telegram DMs: OPEN (telegram.dmPolicy="open"). Anyone who can find the bot can DM it.`, - ); - if (!hasWildcard) { - warnings.push( - `- Telegram DMs: config invalid — dmPolicy "open" requires telegram.allowFrom to include "*".`, - ); - } - } else if (dmPolicy === "disabled") { - warnings.push(`- Telegram DMs: disabled (telegram.dmPolicy="disabled").`); - } else if (allowCount === 0) { - warnings.push( - `- Telegram DMs: locked (telegram.dmPolicy="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`, - ); - warnings.push( - ` Approve via: clawdbot pairing list telegram / clawdbot pairing approve telegram `, - ); + for (const plugin of listProviderPlugins()) { + if (!plugin.security) continue; + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveProviderDefaultAccountId({ + plugin, + cfg, + accountIds, + }); + const account = plugin.config.resolveAccount(cfg, defaultAccountId); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : true; + if (!enabled) continue; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + if (!configured) continue; + const dmPolicy = plugin.security.resolveDmPolicy?.({ + cfg, + accountId: defaultAccountId, + account, + }); + if (dmPolicy) { + await warnDmPolicy({ + label: plugin.meta.label ?? plugin.id, + provider: plugin.id, + dmPolicy: dmPolicy.policy, + allowFrom: dmPolicy.allowFrom, + policyPath: dmPolicy.policyPath, + allowFromPath: dmPolicy.allowFromPath, + approveHint: dmPolicy.approveHint, + normalizeEntry: dmPolicy.normalizeEntry, + }); } - - const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; - const groupAllowlistConfigured = - cfg.telegram?.groups && Object.keys(cfg.telegram.groups).length > 0; - if (groupPolicy === "open" && !groupAllowlistConfigured) { - warnings.push( - `- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`, - ); + if (plugin.security.collectWarnings) { + const extra = await plugin.security.collectWarnings({ + cfg, + accountId: defaultAccountId, + account, + }); + if (extra?.length) warnings.push(...extra); } } - if (cfg.discord?.enabled !== false) { - await warnDmPolicy({ - label: "Discord", - provider: "discord", - dmPolicy: cfg.discord?.dm?.policy ?? "pairing", - allowFrom: cfg.discord?.dm?.allowFrom ?? [], - allowFromPath: "discord.dm.", - approveHint: - "Approve via: clawdbot pairing list discord / clawdbot pairing approve discord ", - normalizeEntry: (raw) => - raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), - }); - } - - if (cfg.slack?.enabled !== false) { - await warnDmPolicy({ - label: "Slack", - provider: "slack", - dmPolicy: cfg.slack?.dm?.policy ?? "pairing", - allowFrom: cfg.slack?.dm?.allowFrom ?? [], - allowFromPath: "slack.dm.", - approveHint: - "Approve via: clawdbot pairing list slack / clawdbot pairing approve slack ", - normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), - }); - } - - if (cfg.signal?.enabled !== false) { - await warnDmPolicy({ - label: "Signal", - provider: "signal", - dmPolicy: cfg.signal?.dmPolicy ?? "pairing", - allowFrom: cfg.signal?.allowFrom ?? [], - allowFromPath: "signal.", - approveHint: - "Approve via: clawdbot pairing list signal / clawdbot pairing approve signal ", - normalizeEntry: (raw) => - normalizeE164(raw.replace(/^signal:/i, "").trim()), - }); - } - - if (cfg.imessage?.enabled !== false) { - await warnDmPolicy({ - label: "iMessage", - provider: "imessage", - dmPolicy: cfg.imessage?.dmPolicy ?? "pairing", - allowFrom: cfg.imessage?.allowFrom ?? [], - allowFromPath: "imessage.", - approveHint: - "Approve via: clawdbot pairing list imessage / clawdbot pairing approve imessage ", - }); - } - - if (cfg.whatsapp) { - await warnDmPolicy({ - label: "WhatsApp", - provider: "whatsapp", - dmPolicy: cfg.whatsapp?.dmPolicy ?? "pairing", - allowFrom: cfg.whatsapp?.allowFrom ?? [], - allowFromPath: "whatsapp.", - approveHint: - "Approve via: clawdbot pairing list whatsapp / clawdbot pairing approve whatsapp ", - normalizeEntry: (raw) => normalizeE164(raw), - }); - } - if (warnings.length > 0) { note(warnings.join("\n"), "Security"); } diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 6e375553a..681072191 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -28,7 +28,15 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => { error: null, close: null, health: { ok: true }, - status: { web: { linked: false }, sessions: { count: 0 } }, + status: { + linkProvider: { + id: "whatsapp", + label: "WhatsApp", + linked: false, + authAgeMs: null, + }, + sessions: { count: 0 }, + }, presence: [ { mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" }, ], @@ -52,7 +60,15 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => { error: null, close: null, health: { ok: true }, - status: { web: { linked: true }, sessions: { count: 2 } }, + status: { + linkProvider: { + id: "whatsapp", + label: "WhatsApp", + linked: true, + authAgeMs: 5_000, + }, + sessions: { count: 2 }, + }, presence: [ { mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }, ], diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts index 37f2d21d6..7bbecde95 100644 --- a/src/commands/health.command.coverage.test.ts +++ b/src/commands/health.command.coverage.test.ts @@ -10,7 +10,7 @@ vi.mock("../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), })); -vi.mock("../web/session.js", () => ({ +vi.mock("../web/auth-store.js", () => ({ webAuthExists: vi.fn(async () => true), getWebAuthAgeMs: vi.fn(() => 0), logWebSelfId: (...args: unknown[]) => logWebSelfIdMock(...args), @@ -32,22 +32,29 @@ describe("healthCommand (coverage)", () => { ok: true, ts: Date.now(), durationMs: 5, - web: { - linked: true, - authAgeMs: 5 * 60_000, - connect: { ok: true, status: 200, elapsedMs: 10 }, - }, - telegram: { - configured: true, - probe: { - ok: true, - elapsedMs: 7, - bot: { username: "bot" }, - webhook: { url: "https://example.com/h" }, + providers: { + whatsapp: { + linked: true, + authAgeMs: 5 * 60_000, + }, + telegram: { + configured: true, + probe: { + ok: true, + elapsedMs: 7, + bot: { username: "bot" }, + webhook: { url: "https://example.com/h" }, + }, + }, + discord: { + configured: false, }, }, - discord: { - configured: false, + providerOrder: ["whatsapp", "telegram", "discord"], + providerLabels: { + whatsapp: "WhatsApp", + telegram: "Telegram", + discord: "Discord", }, heartbeatSeconds: 60, sessions: { @@ -64,7 +71,7 @@ describe("healthCommand (coverage)", () => { expect(runtime.exit).not.toHaveBeenCalled(); expect(runtime.log.mock.calls.map((c) => String(c[0])).join("\n")).toMatch( - /Web: linked/i, + /WhatsApp: linked/i, ); expect(logWebSelfIdMock).toHaveBeenCalled(); }); diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 9d7d4dae6..c37a9898c 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -23,9 +23,10 @@ vi.mock("../config/sessions.js", () => ({ loadSessionStore: () => testStore, })); -vi.mock("../web/session.js", () => ({ +vi.mock("../web/auth-store.js", () => ({ webAuthExists: vi.fn(async () => true), getWebAuthAgeMs: vi.fn(() => 1234), + readWebSelfId: vi.fn(() => ({ e164: null, jid: null })), logWebSelfId: vi.fn(), })); @@ -49,10 +50,16 @@ describe("getHealthSnapshot", () => { }; vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); vi.stubEnv("DISCORD_BOT_TOKEN", ""); - const snap = (await getHealthSnapshot(10)) satisfies HealthSummary; + const snap = (await getHealthSnapshot({ + timeoutMs: 10, + })) satisfies HealthSummary; expect(snap.ok).toBe(true); - expect(snap.telegram.configured).toBe(false); - expect(snap.telegram.probe).toBeUndefined(); + const telegram = snap.providers.telegram as { + configured?: boolean; + probe?: unknown; + }; + expect(telegram.configured).toBe(false); + expect(telegram.probe).toBeUndefined(); expect(snap.sessions.count).toBe(2); expect(snap.sessions.recent[0]?.key).toBe("foo"); }); @@ -98,11 +105,19 @@ describe("getHealthSnapshot", () => { }), ); - const snap = await getHealthSnapshot(25); - expect(snap.telegram.configured).toBe(true); - expect(snap.telegram.probe?.ok).toBe(true); - expect(snap.telegram.probe?.bot?.username).toBe("bot"); - expect(snap.telegram.probe?.webhook?.url).toMatch(/^https:/); + const snap = await getHealthSnapshot({ timeoutMs: 25 }); + const telegram = snap.providers.telegram as { + configured?: boolean; + probe?: { + ok?: boolean; + bot?: { username?: string }; + webhook?: { url?: string }; + }; + }; + expect(telegram.configured).toBe(true); + expect(telegram.probe?.ok).toBe(true); + expect(telegram.probe?.bot?.username).toBe("bot"); + expect(telegram.probe?.webhook?.url).toMatch(/^https:/); expect(calls.some((c) => c.includes("/getMe"))).toBe(true); expect(calls.some((c) => c.includes("/getWebhookInfo"))).toBe(true); }); @@ -151,9 +166,13 @@ describe("getHealthSnapshot", () => { }), ); - const snap = await getHealthSnapshot(25); - expect(snap.telegram.configured).toBe(true); - expect(snap.telegram.probe?.ok).toBe(true); + const snap = await getHealthSnapshot({ timeoutMs: 25 }); + const telegram = snap.providers.telegram as { + configured?: boolean; + probe?: { ok?: boolean }; + }; + expect(telegram.configured).toBe(true); + expect(telegram.probe?.ok).toBe(true); expect(calls.some((c) => c.includes("bott-file/getMe"))).toBe(true); fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -178,11 +197,15 @@ describe("getHealthSnapshot", () => { }), ); - const snap = await getHealthSnapshot(25); - expect(snap.telegram.configured).toBe(true); - expect(snap.telegram.probe?.ok).toBe(false); - expect(snap.telegram.probe?.status).toBe(401); - expect(snap.telegram.probe?.error).toMatch(/unauthorized/i); + const snap = await getHealthSnapshot({ timeoutMs: 25 }); + const telegram = snap.providers.telegram as { + configured?: boolean; + probe?: { ok?: boolean; status?: number; error?: string }; + }; + expect(telegram.configured).toBe(true); + expect(telegram.probe?.ok).toBe(false); + expect(telegram.probe?.status).toBe(401); + expect(telegram.probe?.error).toMatch(/unauthorized/i); }); it("captures unexpected probe exceptions as errors", async () => { @@ -197,9 +220,13 @@ describe("getHealthSnapshot", () => { }), ); - const snap = await getHealthSnapshot(25); - expect(snap.telegram.configured).toBe(true); - expect(snap.telegram.probe?.ok).toBe(false); - expect(snap.telegram.probe?.error).toMatch(/network down/i); + const snap = await getHealthSnapshot({ timeoutMs: 25 }); + const telegram = snap.providers.telegram as { + configured?: boolean; + probe?: { ok?: boolean; error?: string }; + }; + expect(telegram.configured).toBe(true); + expect(telegram.probe?.ok).toBe(false); + expect(telegram.probe?.error).toMatch(/network down/i); }); }); diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index de6b956cd..512198c63 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -21,15 +21,20 @@ describe("healthCommand", () => { it("outputs JSON from gateway", async () => { const snapshot: HealthSummary = { + ok: true, ts: Date.now(), durationMs: 5, - web: { - linked: true, - authAgeMs: 5000, - connect: { ok: true, elapsedMs: 10 }, + providers: { + whatsapp: { linked: true, authAgeMs: 5000 }, + telegram: { configured: true, probe: { ok: true, elapsedMs: 1 } }, + discord: { configured: false }, + }, + providerOrder: ["whatsapp", "telegram", "discord"], + providerLabels: { + whatsapp: "WhatsApp", + telegram: "Telegram", + discord: "Discord", }, - telegram: { configured: true, probe: { ok: true, elapsedMs: 1 } }, - discord: { configured: false }, heartbeatSeconds: 60, sessions: { path: "/tmp/sessions.json", @@ -44,18 +49,27 @@ describe("healthCommand", () => { expect(runtime.exit).not.toHaveBeenCalled(); const logged = runtime.log.mock.calls[0]?.[0] as string; const parsed = JSON.parse(logged) as HealthSummary; - expect(parsed.web.linked).toBe(true); - expect(parsed.telegram.configured).toBe(true); + expect(parsed.providers.whatsapp?.linked).toBe(true); + expect(parsed.providers.telegram?.configured).toBe(true); expect(parsed.sessions.count).toBe(1); }); it("prints text summary when not json", async () => { callGatewayMock.mockResolvedValueOnce({ + ok: true, ts: Date.now(), durationMs: 5, - web: { linked: false, authAgeMs: null }, - telegram: { configured: false }, - discord: { configured: false }, + providers: { + whatsapp: { linked: false, authAgeMs: null }, + telegram: { configured: false }, + discord: { configured: false }, + }, + providerOrder: ["whatsapp", "telegram", "discord"], + providerLabels: { + whatsapp: "WhatsApp", + telegram: "Telegram", + discord: "Discord", + }, heartbeatSeconds: 60, sessions: { path: "/tmp/sessions.json", count: 0, recent: [] }, } satisfies HealthSummary); diff --git a/src/commands/health.ts b/src/commands/health.ts index b63ffa9e7..92c29825f 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,19 +1,26 @@ import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; -import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { probeTelegram, type TelegramProbe } from "../telegram/probe.js"; -import { resolveTelegramToken } from "../telegram/token.js"; -import { resolveWhatsAppAccount } from "../web/accounts.js"; -import { resolveHeartbeatSeconds } from "../web/reconnect.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js"; import { - getWebAuthAgeMs, - logWebSelfId, - webAuthExists, -} from "../web/session.js"; + getProviderPlugin, + listProviderPlugins, +} from "../providers/plugins/index.js"; +import type { ProviderAccountSnapshot } from "../providers/plugins/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveHeartbeatSeconds } from "../web/reconnect.js"; + +export type ProviderHealthSummary = { + configured?: boolean; + linked?: boolean; + authAgeMs?: number | null; + probe?: unknown; + lastProbeAt?: number | null; + [key: string]: unknown; +}; export type HealthSummary = { /** @@ -24,24 +31,9 @@ export type HealthSummary = { ok: true; ts: number; durationMs: number; - web: { - linked: boolean; - authAgeMs: number | null; - connect?: { - ok: boolean; - status?: number | null; - error?: string | null; - elapsedMs?: number | null; - }; - }; - telegram: { - configured: boolean; - probe?: TelegramProbe; - }; - discord: { - configured: boolean; - probe?: DiscordProbe; - }; + providers: Record; + providerOrder: string[]; + providerLabels: Record; heartbeatSeconds: number; sessions: { path: string; @@ -56,13 +48,110 @@ export type HealthSummary = { const DEFAULT_TIMEOUT_MS = 10_000; -export async function getHealthSnapshot( - timeoutMs?: number, -): Promise { +const isAccountEnabled = (account: unknown): boolean => { + if (!account || typeof account !== "object") return true; + const enabled = (account as { enabled?: boolean }).enabled; + return enabled !== false; +}; + +const asRecord = (value: unknown): Record | null => + value && typeof value === "object" + ? (value as Record) + : null; + +const formatProbeLine = (probe: unknown): string | null => { + const record = asRecord(probe); + if (!record) return null; + const ok = typeof record.ok === "boolean" ? record.ok : undefined; + if (ok === undefined) return null; + const elapsedMs = + typeof record.elapsedMs === "number" ? record.elapsedMs : null; + const status = typeof record.status === "number" ? record.status : null; + const error = typeof record.error === "string" ? record.error : null; + const bot = asRecord(record.bot); + const botUsername = + bot && typeof bot.username === "string" ? bot.username : null; + const webhook = asRecord(record.webhook); + const webhookUrl = + webhook && typeof webhook.url === "string" ? webhook.url : null; + + if (ok) { + let label = "ok"; + if (botUsername) label += ` (@${botUsername})`; + if (elapsedMs != null) label += ` (${elapsedMs}ms)`; + if (webhookUrl) label += ` - webhook ${webhookUrl}`; + return label; + } + let label = `failed (${status ?? "unknown"})`; + if (error) label += ` - ${error}`; + return label; +}; + +export const formatHealthProviderLines = (summary: HealthSummary): string[] => { + const providers = summary.providers ?? {}; + const providerOrder = + summary.providerOrder?.length > 0 + ? summary.providerOrder + : Object.keys(providers); + + const lines: string[] = []; + for (const providerId of providerOrder) { + const providerSummary = providers[providerId]; + if (!providerSummary) continue; + const plugin = getProviderPlugin(providerId as never); + const label = + summary.providerLabels?.[providerId] ?? plugin?.meta.label ?? providerId; + const linked = + typeof providerSummary.linked === "boolean" + ? providerSummary.linked + : null; + if (linked !== null) { + if (linked) { + const authAgeMs = + typeof providerSummary.authAgeMs === "number" + ? providerSummary.authAgeMs + : null; + const authLabel = + authAgeMs != null + ? ` (auth age ${Math.round(authAgeMs / 60000)}m)` + : ""; + lines.push(`${label}: linked${authLabel}`); + } else { + lines.push(`${label}: not linked`); + } + continue; + } + + const configured = + typeof providerSummary.configured === "boolean" + ? providerSummary.configured + : null; + if (configured === false) { + lines.push(`${label}: not configured`); + continue; + } + + const probeLine = formatProbeLine(providerSummary.probe); + if (probeLine) { + lines.push(`${label}: ${probeLine}`); + continue; + } + + if (configured === true) { + lines.push(`${label}: configured`); + continue; + } + lines.push(`${label}: unknown`); + } + return lines; +}; + +export async function getHealthSnapshot(params?: { + timeoutMs?: number; + probe?: boolean; +}): Promise { + const timeoutMs = params?.timeoutMs; const cfg = loadConfig(); - const account = resolveWhatsAppAccount({ cfg }); - const linked = await webAuthExists(account.authDir); - const authAgeMs = getWebAuthAgeMs(account.authDir); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); @@ -78,27 +167,81 @@ export async function getHealthSnapshot( const start = Date.now(); const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS); - const { token: telegramToken } = resolveTelegramToken(cfg); - const telegramConfigured = telegramToken.trim().length > 0; - const telegramProxy = cfg.telegram?.proxy; - const telegramProbe = telegramConfigured - ? await probeTelegram(telegramToken.trim(), cappedTimeout, telegramProxy) - : undefined; + const doProbe = params?.probe !== false; + const providers: Record = {}; + const providerOrder = listProviderPlugins().map((plugin) => plugin.id); + const providerLabels: Record = {}; - const discordToken = - process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? ""; - const discordConfigured = discordToken.trim().length > 0; - const discordProbe = discordConfigured - ? await probeDiscord(discordToken.trim(), cappedTimeout) - : undefined; + for (const plugin of listProviderPlugins()) { + providerLabels[plugin.id] = plugin.meta.label ?? plugin.id; + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveProviderDefaultAccountId({ + plugin, + cfg, + accountIds, + }); + const account = plugin.config.resolveAccount(cfg, defaultAccountId); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : isAccountEnabled(account); + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + + let probe: unknown; + let lastProbeAt: number | null = null; + if (enabled && configured && doProbe && plugin.status?.probeAccount) { + try { + probe = await plugin.status.probeAccount({ + account, + timeoutMs: cappedTimeout, + cfg, + }); + lastProbeAt = Date.now(); + } catch (err) { + probe = { ok: false, error: formatErrorMessage(err) }; + lastProbeAt = Date.now(); + } + } + + const snapshot: ProviderAccountSnapshot = { + accountId: defaultAccountId, + enabled, + configured, + }; + if (probe !== undefined) snapshot.probe = probe; + if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt; + + const summary = plugin.status?.buildProviderSummary + ? await plugin.status.buildProviderSummary({ + account, + cfg, + defaultAccountId, + snapshot, + }) + : undefined; + const record = + summary && typeof summary === "object" + ? (summary as ProviderHealthSummary) + : ({ + configured, + probe, + lastProbeAt, + } satisfies ProviderHealthSummary); + if (record.configured === undefined) record.configured = configured; + if (record.lastProbeAt === undefined && lastProbeAt) { + record.lastProbeAt = lastProbeAt; + } + providers[plugin.id] = record; + } const summary: HealthSummary = { ok: true, ts: Date.now(), durationMs: Date.now() - start, - web: { linked, authAgeMs }, - telegram: { configured: telegramConfigured, probe: telegramProbe }, - discord: { configured: discordConfigured, probe: discordProbe }, + providers, + providerOrder, + providerLabels, heartbeatSeconds, sessions: { path: storePath, @@ -140,47 +283,29 @@ export async function healthCommand( runtime.log(` ${line}`); } } - runtime.log( - summary.web.linked - ? `Web: linked (auth age ${summary.web.authAgeMs ? `${Math.round(summary.web.authAgeMs / 60000)}m` : "unknown"})` - : "Web: not linked (run clawdbot providers login)", - ); - if (summary.web.linked) { - const cfg = loadConfig(); - const account = resolveWhatsAppAccount({ cfg }); - logWebSelfId(account.authDir, runtime, true); + for (const line of formatHealthProviderLines(summary)) { + runtime.log(line); } - if (summary.web.connect) { - const base = summary.web.connect.ok - ? info(`Connect: ok (${summary.web.connect.elapsedMs}ms)`) - : `Connect: failed (${summary.web.connect.status ?? "unknown"})`; - runtime.log( - base + - (summary.web.connect.error ? ` - ${summary.web.connect.error}` : ""), - ); + const cfg = loadConfig(); + for (const plugin of listProviderPlugins()) { + const providerSummary = summary.providers?.[plugin.id]; + if (!providerSummary || providerSummary.linked !== true) continue; + if (!plugin.status?.logSelfId) continue; + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveProviderDefaultAccountId({ + plugin, + cfg, + accountIds, + }); + const account = plugin.config.resolveAccount(cfg, defaultAccountId); + plugin.status.logSelfId({ + account, + cfg, + runtime, + includeProviderPrefix: true, + }); } - const tgLabel = summary.telegram.configured - ? summary.telegram.probe?.ok - ? info( - `Telegram: ok${summary.telegram.probe.bot?.username ? ` (@${summary.telegram.probe.bot.username})` : ""} (${summary.telegram.probe.elapsedMs}ms)` + - (summary.telegram.probe.webhook?.url - ? ` - webhook ${summary.telegram.probe.webhook.url}` - : ""), - ) - : `Telegram: failed (${summary.telegram.probe?.status ?? "unknown"})${summary.telegram.probe?.error ? ` - ${summary.telegram.probe.error}` : ""}` - : "Telegram: not configured"; - runtime.log(tgLabel); - - const discordLabel = summary.discord.configured - ? summary.discord.probe?.ok - ? info( - `Discord: ok${summary.discord.probe.bot?.username ? ` (@${summary.discord.probe.bot.username})` : ""} (${summary.discord.probe.elapsedMs}ms)`, - ) - : `Discord: failed (${summary.discord.probe?.status ?? "unknown"})${summary.discord.probe?.error ? ` - ${summary.discord.probe.error}` : ""}` - : "Discord: not configured"; - runtime.log(discordLabel); - runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`)); runtime.log( info( diff --git a/src/commands/message.ts b/src/commands/message.ts index aca886c85..be91c5213 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -1,8 +1,4 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { handleDiscordAction } from "../agents/tools/discord-actions.js"; -import { handleSlackAction } from "../agents/tools/slack-actions.js"; -import { handleTelegramAction } from "../agents/tools/telegram-actions.js"; -import { handleWhatsAppAction } from "../agents/tools/whatsapp-actions.js"; import type { CliDeps } from "../cli/deps.js"; import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; @@ -24,7 +20,13 @@ import { sendPoll, } from "../infra/outbound/message.js"; import { resolveMessageProviderSelection } from "../infra/outbound/provider-selection.js"; +import { dispatchProviderMessageAction } from "../providers/plugins/message-actions.js"; +import type { ProviderMessageActionName } from "../providers/plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; type MessageAction = | "send" @@ -277,6 +279,8 @@ export async function messageCommand( provider: opts.provider, }); const provider = providerSelection.provider; + const accountId = optionalString(opts.account); + const actionParams = opts as Record; const outboundDeps: OutboundSendDeps = { sendWhatsApp: deps.sendMessageWhatsApp, sendTelegram: deps.sendMessageTelegram, @@ -304,7 +308,7 @@ export async function messageCommand( dryRun: opts.dryRun, media: optionalString(opts.media), gifPlayback: opts.gifPlayback, - account: optionalString(opts.account), + account: accountId, }; if (opts.dryRun) { @@ -312,72 +316,20 @@ export async function messageCommand( return; } - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: "sendMessage", - to, - content: message, - mediaUrl: optionalString(opts.media), - replyTo: optionalString(opts.replyTo), - }, - cfg, - ); - const payload = extractToolPayload(result); - if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); - } else { - runtime.log(success(`Sent via ${provider}.`)); - } - return; - } - - if (provider === "slack") { - const result = await handleSlackAction( - { - action: "sendMessage", - to, - content: message, - mediaUrl: optionalString(opts.media), - threadTs: - optionalString(opts.threadId) ?? optionalString(opts.replyTo), - accountId: optionalString(opts.account), - }, - cfg, - ); - const payload = extractToolPayload(result); - if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); - } else { - runtime.log(success(`Sent via ${provider}.`)); - } - return; - } - - if (provider === "telegram") { - const buttonsJson = optionalString(opts.buttonsJson); - let buttons: unknown; - if (buttonsJson) { - try { - buttons = JSON.parse(buttonsJson); - } catch { - throw new Error("buttons-json must be valid JSON"); - } - } - const result = await handleTelegramAction( - { - action: "sendMessage", - to, - content: message, - mediaUrl: optionalString(opts.media), - replyToMessageId: optionalString(opts.replyTo), - messageThreadId: optionalString(opts.threadId), - accountId: optionalString(opts.account), - buttons, - }, - cfg, - ); - const payload = extractToolPayload(result); + const handled = await dispatchProviderMessageAction({ + provider, + action: action as ProviderMessageActionName, + cfg, + params: actionParams, + accountId, + gateway: { + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, + dryRun: opts.dryRun, + }); + if (handled) { + const payload = extractToolPayload(handled); if (opts.json) { runtime.log(JSON.stringify(payload, null, 2)); } else { @@ -400,10 +352,13 @@ export async function messageCommand( provider, mediaUrl: optionalString(opts.media), gifPlayback: opts.gifPlayback, - accountId: optionalString(opts.account), + accountId, dryRun: opts.dryRun, deps: outboundDeps, - gateway: { clientName: "cli", mode: "cli" }, + gateway: { + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, }), ); logSendResult(result, sendOpts, runtime); @@ -434,26 +389,29 @@ export async function messageCommand( durationHours, provider, dryRun: true, - gateway: { clientName: "cli", mode: "cli" }, + gateway: { + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, }); logPollDryRun(result, runtime); return; } - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: "poll", - to, - question, - answers: options, - allowMultiselect, - durationHours: durationHours ?? undefined, - content: optionalString(opts.message), - }, - cfg, - ); - const payload = extractToolPayload(result); + const handled = await dispatchProviderMessageAction({ + provider, + action: action as ProviderMessageActionName, + cfg, + params: actionParams, + accountId, + gateway: { + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, + dryRun: opts.dryRun, + }); + if (handled) { + const payload = extractToolPayload(handled); if (opts.json) { runtime.log(JSON.stringify(payload, null, 2)); } else { @@ -478,7 +436,10 @@ export async function messageCommand( durationHours, provider, dryRun: opts.dryRun, - gateway: { clientName: "cli", mode: "cli" }, + gateway: { + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, }), ); @@ -522,612 +483,24 @@ export async function messageCommand( return; } - if (action === "react") { - const messageId = requireString(opts.messageId, "message-id"); - const emoji = optionalString(opts.emoji) ?? ""; - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: "react", - channelId: requireString(opts.channelId ?? opts.to, "to"), - messageId, - emoji, - remove: opts.remove, - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "slack") { - const result = await handleSlackAction( - { - action: "react", - channelId: requireString(opts.channelId ?? opts.to, "to"), - messageId, - emoji, - remove: opts.remove, - accountId: optionalString(opts.account), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "telegram") { - const result = await handleTelegramAction( - { - action: "react", - chatId: requireString(opts.to, "to"), - messageId, - emoji, - remove: opts.remove, - accountId: optionalString(opts.account), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "whatsapp") { - const result = await handleWhatsAppAction( - { - action: "react", - chatJid: requireString(opts.to, "to"), - messageId, - emoji, - remove: opts.remove, - participant: optionalString(opts.participant), - accountId: optionalString(opts.account), - fromMe: opts.fromMe, - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - throw new Error(`React is not supported for provider ${provider}.`); - } - - if (action === "reactions") { - const messageId = requireString(opts.messageId, "message-id"); - const limit = parseIntOption(opts.limit, "limit"); - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: "reactions", - channelId: requireString(opts.channelId ?? opts.to, "to"), - messageId, - limit, - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "slack") { - const result = await handleSlackAction( - { - action: "reactions", - channelId: requireString(opts.channelId ?? opts.to, "to"), - messageId, - limit, - accountId: optionalString(opts.account), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - throw new Error(`Reactions are not supported for provider ${provider}.`); - } - - if (action === "read") { - const limit = parseIntOption(opts.limit, "limit"); - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: "readMessages", - channelId: requireString(opts.channelId ?? opts.to, "to"), - limit, - before: optionalString(opts.before), - after: optionalString(opts.after), - around: optionalString(opts.around), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "slack") { - const result = await handleSlackAction( - { - action: "readMessages", - channelId: requireString(opts.channelId ?? opts.to, "to"), - limit, - before: optionalString(opts.before), - after: optionalString(opts.after), - accountId: optionalString(opts.account), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - throw new Error(`Read is not supported for provider ${provider}.`); - } - - if (action === "edit") { - const messageId = requireString(opts.messageId, "message-id"); - const message = requireString(opts.message, "message"); - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: "editMessage", - channelId: requireString(opts.channelId ?? opts.to, "to"), - messageId, - content: message, - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "slack") { - const result = await handleSlackAction( - { - action: "editMessage", - channelId: requireString(opts.channelId ?? opts.to, "to"), - messageId, - content: message, - accountId: optionalString(opts.account), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - throw new Error(`Edit is not supported for provider ${provider}.`); - } - - if (action === "delete") { - const messageId = requireString(opts.messageId, "message-id"); - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: "deleteMessage", - channelId: requireString(opts.channelId ?? opts.to, "to"), - messageId, - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "slack") { - const result = await handleSlackAction( - { - action: "deleteMessage", - channelId: requireString(opts.channelId ?? opts.to, "to"), - messageId, - accountId: optionalString(opts.account), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - throw new Error(`Delete is not supported for provider ${provider}.`); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const channelId = requireString(opts.channelId ?? opts.to, "to"); - const messageId = - action === "list-pins" - ? undefined - : requireString(opts.messageId, "message-id"); - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: - action === "pin" - ? "pinMessage" - : action === "unpin" - ? "unpinMessage" - : "listPins", - channelId, - messageId, - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "slack") { - const result = await handleSlackAction( - { - action: - action === "pin" - ? "pinMessage" - : action === "unpin" - ? "unpinMessage" - : "listPins", - channelId, - messageId, - accountId: optionalString(opts.account), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - throw new Error(`Pins are not supported for provider ${provider}.`); - } - - if (action === "permissions") { - if (provider !== "discord") { - throw new Error( - `Permissions are only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "permissions", - channelId: requireString(opts.channelId ?? opts.to, "to"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); + const handled = await dispatchProviderMessageAction({ + provider, + action: action as ProviderMessageActionName, + cfg, + params: actionParams, + accountId, + gateway: { + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, + dryRun: opts.dryRun, + }); + if (handled) { + runtime.log(JSON.stringify(extractToolPayload(handled), null, 2)); return; } - if (action === "thread-create") { - if (provider !== "discord") { - throw new Error( - `Thread create is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "threadCreate", - channelId: requireString(opts.channelId ?? opts.to, "to"), - name: requireString(opts.threadName, "thread-name"), - messageId: optionalString(opts.messageId), - autoArchiveMinutes: parseIntOption( - opts.autoArchiveMin, - "auto-archive-min", - ), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "thread-list") { - if (provider !== "discord") { - throw new Error( - `Thread list is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "threadList", - guildId: requireString(opts.guildId, "guild-id"), - channelId: optionalString(opts.channelId), - includeArchived: opts.includeArchived, - before: optionalString(opts.before), - limit: parseIntOption(opts.limit, "limit"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "thread-reply") { - if (provider !== "discord") { - throw new Error( - `Thread reply is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "threadReply", - channelId: requireString(opts.channelId ?? opts.to, "to"), - content: requireString(opts.message, "message"), - mediaUrl: optionalString(opts.media), - replyTo: optionalString(opts.replyTo), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "search") { - if (provider !== "discord") { - throw new Error( - `Search is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "searchMessages", - guildId: requireString(opts.guildId, "guild-id"), - content: requireString(opts.query, "query"), - channelId: optionalString(opts.channelId), - channelIds: toStringArray(opts.channelIds), - authorId: optionalString(opts.authorId), - authorIds: toStringArray(opts.authorIds), - limit: parseIntOption(opts.limit, "limit"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "sticker") { - if (provider !== "discord") { - throw new Error( - `Sticker send is only supported for Discord (provider=${provider}).`, - ); - } - const stickerIds = toStringArray(opts.stickerId); - if (stickerIds.length === 0) { - throw new Error("sticker-id required"); - } - const result = await handleDiscordAction( - { - action: "sticker", - to: requireString(opts.to, "to"), - stickerIds, - content: optionalString(opts.message), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "member-info") { - const userId = requireString(opts.userId, "user-id"); - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: "memberInfo", - guildId: requireString(opts.guildId, "guild-id"), - userId, - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "slack") { - const result = await handleSlackAction( - { - action: "memberInfo", - userId, - accountId: optionalString(opts.account), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - throw new Error(`Member info is not supported for provider ${provider}.`); - } - - if (action === "role-info") { - if (provider !== "discord") { - throw new Error( - `Role info is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { action: "roleInfo", guildId: requireString(opts.guildId, "guild-id") }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "emoji-list") { - if (provider === "discord") { - const result = await handleDiscordAction( - { - action: "emojiList", - guildId: requireString(opts.guildId, "guild-id"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - if (provider === "slack") { - const result = await handleSlackAction( - { action: "emojiList", accountId: optionalString(opts.account) }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - throw new Error(`Emoji list is not supported for provider ${provider}.`); - } - - if (action === "emoji-upload") { - if (provider !== "discord") { - throw new Error( - `Emoji upload is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "emojiUpload", - guildId: requireString(opts.guildId, "guild-id"), - name: requireString(opts.emojiName, "emoji-name"), - mediaUrl: requireString(opts.media, "media"), - roleIds: toStringArray(opts.roleIds), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "sticker-upload") { - if (provider !== "discord") { - throw new Error( - `Sticker upload is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "stickerUpload", - guildId: requireString(opts.guildId, "guild-id"), - name: requireString(opts.stickerName, "sticker-name"), - description: requireString(opts.stickerDesc, "sticker-desc"), - tags: requireString(opts.stickerTags, "sticker-tags"), - mediaUrl: requireString(opts.media, "media"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "role-add" || action === "role-remove") { - if (provider !== "discord") { - throw new Error( - `Role changes are only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: action === "role-add" ? "roleAdd" : "roleRemove", - guildId: requireString(opts.guildId, "guild-id"), - userId: requireString(opts.userId, "user-id"), - roleId: requireString(opts.roleId, "role-id"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "channel-info") { - if (provider !== "discord") { - throw new Error( - `Channel info is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "channelInfo", - channelId: requireString(opts.channelId, "channel-id"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "channel-list") { - if (provider !== "discord") { - throw new Error( - `Channel list is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "channelList", - guildId: requireString(opts.guildId, "guild-id"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "voice-status") { - if (provider !== "discord") { - throw new Error( - `Voice status is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "voiceStatus", - guildId: requireString(opts.guildId, "guild-id"), - userId: requireString(opts.userId, "user-id"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "event-list") { - if (provider !== "discord") { - throw new Error( - `Event list is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { action: "eventList", guildId: requireString(opts.guildId, "guild-id") }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "event-create") { - if (provider !== "discord") { - throw new Error( - `Event create is only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: "eventCreate", - guildId: requireString(opts.guildId, "guild-id"), - name: requireString(opts.eventName, "event-name"), - startTime: requireString(opts.startTime, "start-time"), - endTime: optionalString(opts.endTime), - description: optionalString(opts.desc), - channelId: optionalString(opts.channelId), - location: optionalString(opts.location), - entityType: optionalString(opts.eventType), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - if (action === "timeout" || action === "kick" || action === "ban") { - if (provider !== "discord") { - throw new Error( - `Moderation actions are only supported for Discord (provider=${provider}).`, - ); - } - const result = await handleDiscordAction( - { - action: action as "timeout" | "kick" | "ban", - guildId: requireString(opts.guildId, "guild-id"), - userId: requireString(opts.userId, "user-id"), - durationMinutes: parseIntOption(opts.durationMin, "duration-min"), - until: optionalString(opts.until), - reason: optionalString(opts.reason), - deleteMessageDays: parseIntOption(opts.deleteDays, "delete-days"), - }, - cfg, - ); - runtime.log(JSON.stringify(extractToolPayload(result), null, 2)); - return; - } - - throw new Error(`Unknown action: ${opts.action ?? "unknown"}`); + throw new Error( + `Action ${action} is not supported for provider ${provider}.`, + ); } diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 7b7ef066d..8e0383cb5 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -19,6 +19,10 @@ import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { VERSION } from "../version.js"; import type { @@ -332,8 +336,8 @@ export async function probeGatewayReachable(params: { password: params.password, method: "health", timeoutMs, - clientName: "clawdbot-probe", - mode: "probe", + clientName: GATEWAY_CLIENT_NAMES.PROBE, + mode: GATEWAY_CLIENT_MODES.PROBE, }); return { ok: true }; } catch (err) { diff --git a/src/commands/onboard-non-interactive.gateway-auth.test.ts b/src/commands/onboard-non-interactive.gateway-auth.test.ts index 625afffa2..b4b6cc37a 100644 --- a/src/commands/onboard-non-interactive.gateway-auth.test.ts +++ b/src/commands/onboard-non-interactive.gateway-auth.test.ts @@ -8,6 +8,10 @@ import { WebSocket } from "ws"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; import { rawDataToString } from "../infra/ws.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; async function getFreePort(): Promise { return await new Promise((resolve, reject) => { @@ -66,10 +70,11 @@ async function connectReq(params: { url: string; token?: string }) { minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { - name: "vitest", + id: GATEWAY_CLIENT_NAMES.TEST, + displayName: "vitest", version: "dev", platform: process.platform, - mode: "test", + mode: GATEWAY_CLIENT_MODES.TEST, }, caps: [], auth: params.token ? { token: params.token } : undefined, diff --git a/src/commands/onboard-non-interactive.lan-auto-token.test.ts b/src/commands/onboard-non-interactive.lan-auto-token.test.ts index 3750dc2ae..6fc8d71af 100644 --- a/src/commands/onboard-non-interactive.lan-auto-token.test.ts +++ b/src/commands/onboard-non-interactive.lan-auto-token.test.ts @@ -8,6 +8,10 @@ import { WebSocket } from "ws"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; import { rawDataToString } from "../infra/ws.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; async function getFreePort(): Promise { return await new Promise((resolve, reject) => { @@ -91,10 +95,11 @@ async function connectReq(params: { url: string; token?: string }) { minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { - name: "vitest", + id: GATEWAY_CLIENT_NAMES.TEST, + displayName: "vitest", version: "dev", platform: process.platform, - mode: "test", + mode: GATEWAY_CLIENT_MODES.TEST, }, caps: [], auth: params.token ? { token: params.token } : undefined, diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 28465d74e..2cd4c8f4e 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -1,120 +1,23 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import type { ClawdbotConfig } from "../config/config.js"; -import { mergeWhatsAppConfig } from "../config/merge-config.js"; import type { DmPolicy } from "../config/types.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "../discord/accounts.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "../imessage/accounts.js"; -import { loginWeb } from "../provider-web.js"; import { formatProviderPrimerLine, formatProviderSelectionLine, + getChatProviderMeta, listChatProviders, } from "../providers/registry.js"; -import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, -} from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "../signal/accounts.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, -} from "../slack/accounts.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "../telegram/accounts.js"; import { formatDocsLink } from "../terminal/links.js"; -import { normalizeE164 } from "../utils.js"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAuthDir, -} from "../web/accounts.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -import { detectBinary } from "./onboard-helpers.js"; import type { ProviderChoice } from "./onboard-types.js"; -import { installSignalCli } from "./signal-install.js"; - -async function promptAccountId(params: { - cfg: ClawdbotConfig; - prompter: WizardPrompter; - label: string; - currentId?: string; - listAccountIds: (cfg: ClawdbotConfig) => string[]; - defaultAccountId: string; -}): Promise { - const existingIds = params.listAccountIds(params.cfg); - const initial = - params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID; - const choice = (await params.prompter.select({ - message: `${params.label} account`, - options: [ - ...existingIds.map((id) => ({ - value: id, - label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, - })), - { value: "__new__", label: "Add a new account" }, - ], - initialValue: initial, - })) as string; - - if (choice !== "__new__") return normalizeAccountId(choice); - - const entered = await params.prompter.text({ - message: `New ${params.label} account id`, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - const normalized = normalizeAccountId(String(entered)); - if (String(entered).trim() !== normalized) { - await params.prompter.note( - `Normalized account id to "${normalized}".`, - `${params.label} account`, - ); - } - return normalized; -} - -function addWildcardAllowFrom( - allowFrom?: Array | null, -): Array { - const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean); - if (!next.includes("*")) next.push("*"); - return next; -} - -async function pathExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - -async function detectWhatsAppLinked( - cfg: ClawdbotConfig, - accountId: string, -): Promise { - const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); - const credsPath = path.join(authDir, "creds.json"); - return await pathExists(credsPath); -} +import { + getProviderOnboardingAdapter, + listProviderOnboardingAdapters, +} from "./onboarding/registry.js"; +import type { + ProviderOnboardingDmPolicy, + SetupProvidersOptions, +} from "./onboarding/types.js"; async function noteProviderPrimer(prompter: WizardPrompter): Promise { const providerLines = listChatProviders().map((meta) => @@ -133,245 +36,17 @@ async function noteProviderPrimer(prompter: WizardPrompter): Promise { ); } -async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Open Telegram and chat with @BotFather", - "2) Run /newbot (or /mybots)", - "3) Copy the token (looks like 123456:ABC...)", - "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://clawd.bot", - ].join("\n"), - "Telegram bot token", - ); -} - -async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Discord Developer Portal → Applications → New Application", - "2) Bot → Add Bot → Reset Token → copy token", - "3) OAuth2 → URL Generator → scope 'bot' → invite to your server", - "Tip: enable Message Content Intent if you need message text.", - `Docs: ${formatDocsLink("/discord", "discord")}`, - ].join("\n"), - "Discord bot token", - ); -} - -function buildSlackManifest(botName: string) { - const safeName = botName.trim() || "Clawdbot"; - const manifest = { - display_information: { - name: safeName, - description: `${safeName} connector for Clawdbot`, - }, - features: { - bot_user: { - display_name: safeName, - always_online: false, - }, - app_home: { - messages_tab_enabled: true, - messages_tab_read_only_enabled: false, - }, - slash_commands: [ - { - command: "/clawd", - description: "Send a message to Clawdbot", - should_escape: false, - }, - ], - }, - oauth_config: { - scopes: { - bot: [ - "chat:write", - "channels:history", - "channels:read", - "groups:history", - "im:history", - "mpim:history", - "users:read", - "app_mentions:read", - "reactions:read", - "reactions:write", - "pins:read", - "pins:write", - "emoji:read", - "commands", - "files:read", - "files:write", - ], - }, - }, - settings: { - socket_mode_enabled: true, - event_subscriptions: { - bot_events: [ - "app_mention", - "message.channels", - "message.groups", - "message.im", - "message.mpim", - "reaction_added", - "reaction_removed", - "member_joined_channel", - "member_left_channel", - "channel_rename", - "pin_added", - "pin_removed", - ], - }, - }, - }; - return JSON.stringify(manifest, null, 2); -} - -async function noteSlackTokenHelp( - prompter: WizardPrompter, - botName: string, -): Promise { - const manifest = buildSlackManifest(botName); - await prompter.note( - [ - "1) Slack API → Create App → From scratch", - "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", - "3) OAuth & Permissions → install app to workspace (xoxb- bot token)", - "4) Enable Event Subscriptions (socket) for message events", - "5) App Home → enable the Messages tab for DMs", - "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - "", - "Manifest (JSON):", - manifest, - ].join("\n"), - "Slack socket mode tokens", - ); -} - -export { mergeWhatsAppConfig }; - -export function setWhatsAppDmPolicy( - cfg: ClawdbotConfig, - dmPolicy: DmPolicy, -): ClawdbotConfig { - return mergeWhatsAppConfig(cfg, { dmPolicy }); -} - -export function setWhatsAppAllowFrom( - cfg: ClawdbotConfig, - allowFrom?: string[], -): ClawdbotConfig { - return mergeWhatsAppConfig( - cfg, - { allowFrom }, - { unsetOnUndefined: ["allowFrom"] }, - ); -} - -function setMessagesResponsePrefix( - cfg: ClawdbotConfig, - responsePrefix?: string, -): ClawdbotConfig { - return { - ...cfg, - messages: { - ...cfg.messages, - responsePrefix, - }, - }; -} - -export function setWhatsAppSelfChatMode( - cfg: ClawdbotConfig, - selfChatMode: boolean, -): ClawdbotConfig { - return mergeWhatsAppConfig(cfg, { selfChatMode }); -} - -function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" - ? addWildcardAllowFrom(cfg.telegram?.allowFrom) - : undefined; - return { - ...cfg, - telegram: { - ...cfg.telegram, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }; -} - -function setDiscordDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" - ? addWildcardAllowFrom(cfg.discord?.dm?.allowFrom) - : undefined; - return { - ...cfg, - discord: { - ...cfg.discord, - dm: { - ...cfg.discord?.dm, - enabled: cfg.discord?.dm?.enabled ?? true, - policy: dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }; -} - -function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" - ? addWildcardAllowFrom(cfg.slack?.dm?.allowFrom) - : undefined; - return { - ...cfg, - slack: { - ...cfg.slack, - dm: { - ...cfg.slack?.dm, - enabled: cfg.slack?.dm?.enabled ?? true, - policy: dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }; -} - -function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" - ? addWildcardAllowFrom(cfg.signal?.allowFrom) - : undefined; - return { - ...cfg, - signal: { - ...cfg.signal, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }; -} - -function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { - const allowFrom = - dmPolicy === "open" - ? addWildcardAllowFrom(cfg.imessage?.allowFrom) - : undefined; - return { - ...cfg, - imessage: { - ...cfg.imessage, - dmPolicy, - ...(allowFrom ? { allowFrom } : {}), - }, - }; +function resolveQuickstartDefault( + statusByProvider: Map, +): ProviderChoice | undefined { + let best: { provider: ProviderChoice; score: number } | null = null; + for (const [provider, status] of statusByProvider) { + if (status.quickstartScore == null) continue; + if (!best || status.quickstartScore > best.score) { + best = { provider, score: status.quickstartScore }; + } + } + return best?.provider; } async function maybeConfigureDmPolicies(params: { @@ -380,10 +55,10 @@ async function maybeConfigureDmPolicies(params: { prompter: WizardPrompter; }): Promise { const { selection, prompter } = params; - const supportsDmPolicy = selection.some((p) => - ["telegram", "discord", "slack", "signal", "imessage"].includes(p), - ); - if (!supportsDmPolicy) return params.cfg; + const dmPolicies = selection + .map((provider) => getProviderOnboardingAdapter(provider)?.dmPolicy) + .filter(Boolean) as ProviderOnboardingDmPolicy[]; + if (dmPolicies.length === 0) return params.cfg; const wants = await prompter.confirm({ message: "Configure DM access policies now? (default: pairing)", @@ -392,23 +67,18 @@ async function maybeConfigureDmPolicies(params: { if (!wants) return params.cfg; let cfg = params.cfg; - const selectPolicy = async (params: { - label: string; - provider: ProviderChoice; - policyKey: string; - allowFromKey: string; - }) => { + const selectPolicy = async (policy: ProviderOnboardingDmPolicy) => { await prompter.note( [ "Default: pairing (unknown DMs get a pairing code).", - `Approve: clawdbot pairing approve ${params.provider} `, - `Public DMs: ${params.policyKey}="open" + ${params.allowFromKey} includes "*".`, + `Approve: clawdbot pairing approve ${policy.provider} `, + `Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`, `Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`, ].join("\n"), - `${params.label} DM access`, + `${policy.label} DM access`, ); return (await prompter.select({ - message: `${params.label} DM policy`, + message: `${policy.label} DM policy`, options: [ { value: "pairing", label: "Pairing (recommended)" }, { value: "open", label: "Open (public inbound DMs)" }, @@ -417,406 +87,18 @@ async function maybeConfigureDmPolicies(params: { })) as DmPolicy; }; - if (selection.includes("telegram")) { - const current = cfg.telegram?.dmPolicy ?? "pairing"; - const policy = await selectPolicy({ - label: "Telegram", - provider: "telegram", - policyKey: "telegram.dmPolicy", - allowFromKey: "telegram.allowFrom", - }); - if (policy !== current) cfg = setTelegramDmPolicy(cfg, policy); - } - if (selection.includes("discord")) { - const current = cfg.discord?.dm?.policy ?? "pairing"; - const policy = await selectPolicy({ - label: "Discord", - provider: "discord", - policyKey: "discord.dm.policy", - allowFromKey: "discord.dm.allowFrom", - }); - if (policy !== current) cfg = setDiscordDmPolicy(cfg, policy); - } - if (selection.includes("slack")) { - const current = cfg.slack?.dm?.policy ?? "pairing"; - const policy = await selectPolicy({ - label: "Slack", - provider: "slack", - policyKey: "slack.dm.policy", - allowFromKey: "slack.dm.allowFrom", - }); - if (policy !== current) cfg = setSlackDmPolicy(cfg, policy); - } - if (selection.includes("signal")) { - const current = cfg.signal?.dmPolicy ?? "pairing"; - const policy = await selectPolicy({ - label: "Signal", - provider: "signal", - policyKey: "signal.dmPolicy", - allowFromKey: "signal.allowFrom", - }); - if (policy !== current) cfg = setSignalDmPolicy(cfg, policy); - } - if (selection.includes("imessage")) { - const current = cfg.imessage?.dmPolicy ?? "pairing"; - const policy = await selectPolicy({ - label: "iMessage", - provider: "imessage", - policyKey: "imessage.dmPolicy", - allowFromKey: "imessage.allowFrom", - }); - if (policy !== current) cfg = setIMessageDmPolicy(cfg, policy); + for (const policy of dmPolicies) { + const current = policy.getCurrent(cfg); + const nextPolicy = await selectPolicy(policy); + if (nextPolicy !== current) { + cfg = policy.setPolicy(cfg, nextPolicy); + } } + return cfg; } -function parseTelegramAllowFromEntries(raw: string): { - entries: string[]; - hasUsernames: boolean; - error?: string; -} { - const parts = raw - .split(/[\n,;]+/g) - .map((part) => part.trim()) - .filter(Boolean); - if (parts.length === 0) { - return { entries: [], hasUsernames: false, error: "Required" }; - } - const entries: string[] = []; - let hasUsernames = false; - for (const part of parts) { - if (part === "*") { - entries.push(part); - continue; - } - const match = part.match(/^(telegram|tg):(.+)$/i); - const value = match ? match[2]?.trim() : part; - if (!value) { - return { - entries: [], - hasUsernames: false, - error: `Invalid entry: ${part}`, - }; - } - if (/^\d+$/.test(value)) { - entries.push(part); - continue; - } - if (value.startsWith("@")) { - const username = value.slice(1); - if (!/^[a-z0-9_]{5,32}$/i.test(username)) { - return { - entries: [], - hasUsernames: false, - error: `Invalid username: ${part}`, - }; - } - hasUsernames = true; - entries.push(part); - continue; - } - if (/^[a-z0-9_]{5,32}$/i.test(value)) { - hasUsernames = true; - entries.push(`@${value}`); - continue; - } - return { - entries: [], - hasUsernames: false, - error: `Invalid entry: ${part}`, - }; - } - return { entries, hasUsernames }; -} - -async function promptTelegramAllowFrom(params: { - cfg: ClawdbotConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const { cfg, prompter, accountId } = params; - const resolved = resolveTelegramAccount({ cfg, accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - const entry = await prompter.text({ - message: "Telegram allowFrom (user id or @username)", - placeholder: "123456789, @myhandle", - initialValue: existingAllowFrom[0] - ? String(existingAllowFrom[0]) - : undefined, - validate: (value) => { - const raw = String(value ?? "").trim(); - const parsed = parseTelegramAllowFromEntries(raw); - return parsed.error; - }, - }); - const parsed = parseTelegramAllowFromEntries(String(entry).trim()); - if (parsed.hasUsernames) { - await prompter.note( - [ - "Usernames can change; numeric user IDs are more stable.", - "Tip: DM the bot and it will reply with your user ID (pairing message).", - ].join("\n"), - "Telegram allowFrom", - ); - } - const merged = [ - ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), - ...parsed.entries, - ]; - const unique = [...new Set(merged)]; - - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - telegram: { - ...cfg.telegram, - enabled: true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }; - } - - return { - ...cfg, - telegram: { - ...cfg.telegram, - enabled: true, - accounts: { - ...cfg.telegram?.accounts, - [accountId]: { - ...cfg.telegram?.accounts?.[accountId], - enabled: cfg.telegram?.accounts?.[accountId]?.enabled ?? true, - dmPolicy: "allowlist", - allowFrom: unique, - }, - }, - }, - }; -} - -async function promptWhatsAppAllowFrom( - cfg: ClawdbotConfig, - _runtime: RuntimeEnv, - prompter: WizardPrompter, - options?: { forceAllowlist?: boolean }, -): Promise { - const existingPolicy = cfg.whatsapp?.dmPolicy ?? "pairing"; - const existingAllowFrom = cfg.whatsapp?.allowFrom ?? []; - const existingLabel = - existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; - const existingResponsePrefix = cfg.messages?.responsePrefix; - - if (options?.forceAllowlist) { - await prompter.note( - "We need the sender/owner number so Clawdbot can allowlist you.", - "WhatsApp number", - ); - const entry = await prompter.text({ - message: - "Your personal WhatsApp number (the phone you will message from)", - placeholder: "+15555550123", - initialValue: existingAllowFrom[0], - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) return "Required"; - const normalized = normalizeE164(raw); - if (!normalized) return `Invalid number: ${raw}`; - return undefined; - }, - }); - const normalized = normalizeE164(String(entry).trim()); - const merged = [ - ...existingAllowFrom - .filter((item) => item !== "*") - .map((item) => normalizeE164(item)) - .filter(Boolean), - normalized, - ]; - const unique = [...new Set(merged.filter(Boolean))]; - let next = setWhatsAppSelfChatMode(cfg, true); - next = setWhatsAppDmPolicy(next, "allowlist"); - next = setWhatsAppAllowFrom(next, unique); - if (existingResponsePrefix === undefined) { - next = setMessagesResponsePrefix(next, "[clawdbot]"); - } - await prompter.note( - [ - "Allowlist mode enabled.", - `- allowFrom includes ${normalized}`, - existingResponsePrefix === undefined - ? "- responsePrefix set to [clawdbot]" - : "- responsePrefix left unchanged", - ].join("\n"), - "WhatsApp allowlist", - ); - return next; - } - - await prompter.note( - [ - "WhatsApp direct chats are gated by `whatsapp.dmPolicy` + `whatsapp.allowFrom`.", - "- pairing (default): unknown senders get a pairing code; owner approves", - "- allowlist: unknown senders are blocked", - '- open: public inbound DMs (requires allowFrom to include "*")', - "- disabled: ignore WhatsApp DMs", - "", - `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, - `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, - ].join("\n"), - "WhatsApp DM access", - ); - - const phoneMode = (await prompter.select({ - message: "WhatsApp phone setup", - options: [ - { value: "personal", label: "This is my personal phone number" }, - { value: "separate", label: "Separate phone just for Clawdbot" }, - ], - })) as "personal" | "separate"; - - if (phoneMode === "personal") { - await prompter.note( - "We need the sender/owner number so Clawdbot can allowlist you.", - "WhatsApp number", - ); - const entry = await prompter.text({ - message: - "Your personal WhatsApp number (the phone you will message from)", - placeholder: "+15555550123", - initialValue: existingAllowFrom[0], - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) return "Required"; - const normalized = normalizeE164(raw); - if (!normalized) return `Invalid number: ${raw}`; - return undefined; - }, - }); - const normalized = normalizeE164(String(entry).trim()); - const merged = [ - ...existingAllowFrom - .filter((item) => item !== "*") - .map((item) => normalizeE164(item)) - .filter(Boolean), - normalized, - ]; - const unique = [...new Set(merged.filter(Boolean))]; - let next = setWhatsAppSelfChatMode(cfg, true); - next = setWhatsAppDmPolicy(next, "allowlist"); - next = setWhatsAppAllowFrom(next, unique); - if (existingResponsePrefix === undefined) { - next = setMessagesResponsePrefix(next, "[clawdbot]"); - } - await prompter.note( - [ - "Personal phone mode enabled.", - "- dmPolicy set to allowlist (pairing skipped)", - `- allowFrom includes ${normalized}`, - existingResponsePrefix === undefined - ? "- responsePrefix set to [clawdbot]" - : "- responsePrefix left unchanged", - ].join("\n"), - "WhatsApp personal phone", - ); - return next; - } - - const policy = (await prompter.select({ - message: "WhatsApp DM policy", - options: [ - { value: "pairing", label: "Pairing (recommended)" }, - { value: "allowlist", label: "Allowlist only (block unknown senders)" }, - { value: "open", label: "Open (public inbound DMs)" }, - { value: "disabled", label: "Disabled (ignore WhatsApp DMs)" }, - ], - })) as DmPolicy; - - let next = setWhatsAppSelfChatMode(cfg, false); - next = setWhatsAppDmPolicy(next, policy); - if (policy === "open") { - next = setWhatsAppAllowFrom(next, ["*"]); - } - if (policy === "disabled") return next; - - const allowOptions = - existingAllowFrom.length > 0 - ? ([ - { value: "keep", label: "Keep current allowFrom" }, - { - value: "unset", - label: "Unset allowFrom (use pairing approvals only)", - }, - { value: "list", label: "Set allowFrom to specific numbers" }, - ] as const) - : ([ - { value: "unset", label: "Unset allowFrom (default)" }, - { value: "list", label: "Set allowFrom to specific numbers" }, - ] as const); - - const mode = (await prompter.select({ - message: "WhatsApp allowFrom (optional pre-allowlist)", - options: allowOptions.map((opt) => ({ - value: opt.value, - label: opt.label, - })), - })) as (typeof allowOptions)[number]["value"]; - - if (mode === "keep") { - // Keep allowFrom as-is. - } else if (mode === "unset") { - next = setWhatsAppAllowFrom(next, undefined); - } else { - const allowRaw = await prompter.text({ - message: "Allowed sender numbers (comma-separated, E.164)", - placeholder: "+15555550123, +447700900123", - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) return "Required"; - const parts = raw - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - if (parts.length === 0) return "Required"; - for (const part of parts) { - if (part === "*") continue; - const normalized = normalizeE164(part); - if (!normalized) return `Invalid number: ${part}`; - } - return undefined; - }, - }); - - const parts = String(allowRaw) - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - const normalized = parts.map((part) => - part === "*" ? "*" : normalizeE164(part), - ); - const unique = [...new Set(normalized.filter(Boolean))]; - next = setWhatsAppAllowFrom(next, unique); - } - - return next; -} - -type SetupProvidersOptions = { - allowDisable?: boolean; - allowSignalInstall?: boolean; - onSelection?: (selection: ProviderChoice[]) => void; - accountIds?: Partial>; - onAccountId?: (provider: ProviderChoice, accountId: string) => void; - promptAccountIds?: boolean; - whatsappAccountId?: string; - promptWhatsAppAccountId?: boolean; - onWhatsAppAccountId?: (accountId: string) => void; - forceAllowFromProviders?: ProviderChoice[]; - skipDmPolicyPrompt?: boolean; - skipConfirm?: boolean; - quickstartDefaults?: boolean; - initialSelection?: ProviderChoice[]; -}; +// Provider-specific prompts moved into onboarding adapters. export async function setupProviders( cfg: ClawdbotConfig, @@ -827,59 +109,25 @@ export async function setupProviders( const forceAllowFromProviders = new Set( options?.forceAllowFromProviders ?? [], ); - const forceTelegramAllowFrom = forceAllowFromProviders.has("telegram"); - const forceWhatsAppAllowFrom = forceAllowFromProviders.has("whatsapp"); + const accountOverrides: Partial> = { + ...options?.accountIds, + }; + if (options?.whatsappAccountId?.trim()) { + accountOverrides.whatsapp = options.whatsappAccountId.trim(); + } - let whatsappAccountId = - options?.whatsappAccountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); - let whatsappLinked = await detectWhatsAppLinked(cfg, whatsappAccountId); - const telegramEnv = Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim()); - const discordEnv = Boolean(process.env.DISCORD_BOT_TOKEN?.trim()); - const slackBotEnv = Boolean(process.env.SLACK_BOT_TOKEN?.trim()); - const slackAppEnv = Boolean(process.env.SLACK_APP_TOKEN?.trim()); - const telegramConfigured = listTelegramAccountIds(cfg).some((accountId) => - Boolean(resolveTelegramAccount({ cfg, accountId }).token), + const statusEntries = await Promise.all( + listProviderOnboardingAdapters().map((adapter) => + adapter.getStatus({ cfg, options, accountOverrides }), + ), ); - const discordConfigured = listDiscordAccountIds(cfg).some((accountId) => - Boolean(resolveDiscordAccount({ cfg, accountId }).token), - ); - const slackConfigured = listSlackAccountIds(cfg).some((accountId) => { - const account = resolveSlackAccount({ cfg, accountId }); - return Boolean(account.botToken && account.appToken); - }); - const signalConfigured = listSignalAccountIds(cfg).some( - (accountId) => resolveSignalAccount({ cfg, accountId }).configured, - ); - const signalCliPath = cfg.signal?.cliPath ?? "signal-cli"; - const signalCliDetected = await detectBinary(signalCliPath); - const imessageConfigured = 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, - ); - }); - const imessageCliPath = cfg.imessage?.cliPath ?? "imsg"; - const imessageCliDetected = await detectBinary(imessageCliPath); - - const waAccountLabel = - whatsappAccountId === DEFAULT_ACCOUNT_ID ? "default" : whatsappAccountId; - await prompter.note( - [ - `Telegram: ${telegramConfigured ? "configured" : "needs token"}`, - `WhatsApp (${waAccountLabel}): ${whatsappLinked ? "linked" : "not linked"}`, - `Discord: ${discordConfigured ? "configured" : "needs token"}`, - `Slack: ${slackConfigured ? "configured" : "needs tokens"}`, - `Signal: ${signalConfigured ? "configured" : "needs setup"}`, - `iMessage: ${imessageConfigured ? "configured" : "needs setup"}`, - `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, - `imsg: ${imessageCliDetected ? "found" : "missing"} (${imessageCliPath})`, - ].join("\n"), - "Provider status", + const statusByProvider = new Map( + statusEntries.map((entry) => [entry.provider, entry]), ); + const statusLines = statusEntries.flatMap((entry) => entry.statusLines); + if (statusLines.length > 0) { + await prompter.note(statusLines.join("\n"), "Provider status"); + } const shouldConfigure = options?.skipConfirm ? true @@ -892,53 +140,18 @@ export async function setupProviders( await noteProviderPrimer(prompter); const selectionOptions = listChatProviders().map((meta) => { - switch (meta.id) { - case "telegram": - return { - value: meta.id, - label: meta.selectionLabel, - hint: telegramConfigured - ? "recommended · configured" - : "recommended · newcomer-friendly", - }; - case "whatsapp": - return { - value: meta.id, - label: meta.selectionLabel, - hint: whatsappLinked ? "linked" : "not linked", - }; - case "discord": - return { - value: meta.id, - label: meta.selectionLabel, - hint: discordConfigured ? "configured" : "needs token", - }; - case "slack": - return { - value: meta.id, - label: meta.selectionLabel, - hint: slackConfigured ? "configured" : "needs tokens", - }; - case "signal": - return { - value: meta.id, - label: meta.selectionLabel, - hint: signalCliDetected ? "signal-cli found" : "signal-cli missing", - }; - case "imessage": - return { - value: meta.id, - label: meta.selectionLabel, - hint: imessageCliDetected ? "imsg found" : "imsg missing", - }; - default: - return { - value: meta.id, - label: meta.selectionLabel, - }; - } + const status = statusByProvider.get(meta.id as ProviderChoice); + return { + value: meta.id, + label: meta.selectionLabel, + ...(status?.selectionHint ? { hint: status.selectionHint } : {}), + }; }); + const quickstartDefault = + options?.initialSelection?.[0] ?? + resolveQuickstartDefault(statusByProvider); + let selection: ProviderChoice[]; if (options?.quickstartDefaults) { const choice = (await prompter.select({ @@ -951,9 +164,7 @@ export async function setupProviders( hint: "You can add providers later via `clawdbot providers add`", }, ], - initialValue: - options?.initialSelection?.[0] ?? - (!telegramConfigured ? "telegram" : "whatsapp"), + initialValue: quickstartDefault, })) as ProviderChoice | "__skip__"; selection = choice === "__skip__" ? [] : [choice]; } else { @@ -966,18 +177,6 @@ export async function setupProviders( } options?.onSelection?.(selection); - const accountOverrides: Partial> = { - ...options?.accountIds, - }; - if (options?.whatsappAccountId?.trim()) { - accountOverrides.whatsapp = options.whatsappAccountId.trim(); - } - const recordAccount = (provider: ProviderChoice, accountId: string) => { - options?.onAccountId?.(provider, accountId); - if (provider === "whatsapp") { - options?.onWhatsAppAccountId?.(accountId); - } - }; const selectionNotes = new Map( listChatProviders().map((meta) => [ @@ -993,649 +192,29 @@ export async function setupProviders( } const shouldPromptAccountIds = options?.promptAccountIds === true; + const recordAccount = (provider: ProviderChoice, accountId: string) => { + options?.onAccountId?.(provider, accountId); + const adapter = getProviderOnboardingAdapter(provider); + adapter?.onAccountRecorded?.(accountId, options); + }; let next = cfg; - - if (selection.includes("whatsapp")) { - const overrideId = accountOverrides.whatsapp?.trim(); - if (overrideId) { - whatsappAccountId = normalizeAccountId(overrideId); - } else if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) { - whatsappAccountId = await promptAccountId({ - cfg: next, - prompter, - label: "WhatsApp", - currentId: whatsappAccountId, - listAccountIds: listWhatsAppAccountIds, - defaultAccountId: resolveDefaultWhatsAppAccountId(next), - }); - } - - if (whatsappAccountId !== DEFAULT_ACCOUNT_ID) { - next = { - ...next, - whatsapp: { - ...next.whatsapp, - accounts: { - ...next.whatsapp?.accounts, - [whatsappAccountId]: { - ...next.whatsapp?.accounts?.[whatsappAccountId], - enabled: - next.whatsapp?.accounts?.[whatsappAccountId]?.enabled ?? true, - }, - }, - }, - }; - } - - recordAccount("whatsapp", whatsappAccountId); - whatsappLinked = await detectWhatsAppLinked(next, whatsappAccountId); - const { authDir } = resolveWhatsAppAuthDir({ + for (const provider of selection) { + const adapter = getProviderOnboardingAdapter(provider); + if (!adapter) continue; + const result = await adapter.configure({ cfg: next, - accountId: whatsappAccountId, + runtime, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom: forceAllowFromProviders.has(provider), }); - - if (!whatsappLinked) { - await prompter.note( - [ - "Scan the QR with WhatsApp on your phone.", - `Credentials are stored under ${authDir}/ for future runs.`, - `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, - ].join("\n"), - "WhatsApp linking", - ); + next = result.cfg; + if (result.accountId) { + recordAccount(provider, result.accountId); } - const wantsLink = await prompter.confirm({ - message: whatsappLinked - ? "WhatsApp already linked. Re-link now?" - : "Link WhatsApp now (QR)?", - initialValue: !whatsappLinked, - }); - if (wantsLink) { - try { - await loginWeb(false, "web", undefined, runtime, whatsappAccountId); - } catch (err) { - runtime.error(`WhatsApp login failed: ${String(err)}`); - await prompter.note( - `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, - "WhatsApp help", - ); - } - } else if (!whatsappLinked) { - await prompter.note( - "Run `clawdbot providers login` later to link WhatsApp.", - "WhatsApp", - ); - } - - next = await promptWhatsAppAllowFrom(next, runtime, prompter, { - forceAllowlist: forceWhatsAppAllowFrom, - }); - } - - if (selection.includes("telegram")) { - const telegramOverride = accountOverrides.telegram?.trim(); - const defaultTelegramAccountId = resolveDefaultTelegramAccountId(next); - let telegramAccountId = telegramOverride - ? normalizeAccountId(telegramOverride) - : defaultTelegramAccountId; - if (shouldPromptAccountIds && !telegramOverride) { - telegramAccountId = await promptAccountId({ - cfg: next, - prompter, - label: "Telegram", - currentId: telegramAccountId, - listAccountIds: listTelegramAccountIds, - defaultAccountId: defaultTelegramAccountId, - }); - } - recordAccount("telegram", telegramAccountId); - - const resolvedAccount = resolveTelegramAccount({ - cfg: next, - accountId: telegramAccountId, - }); - const accountConfigured = Boolean(resolvedAccount.token); - const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID; - const canUseEnv = allowEnv && telegramEnv; - const hasConfigToken = Boolean( - resolvedAccount.config.botToken || resolvedAccount.config.tokenFile, - ); - - let token: string | null = null; - if (!accountConfigured) { - await noteTelegramTokenHelp(prompter); - } - if (canUseEnv && !resolvedAccount.config.botToken) { - const keepEnv = await prompter.confirm({ - message: "TELEGRAM_BOT_TOKEN detected. Use env var?", - initialValue: true, - }); - if (keepEnv) { - next = { - ...next, - telegram: { - ...next.telegram, - enabled: true, - }, - }; - } else { - token = String( - await prompter.text({ - message: "Enter Telegram bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } else if (hasConfigToken) { - const keep = await prompter.confirm({ - message: "Telegram token already configured. Keep it?", - initialValue: true, - }); - if (!keep) { - token = String( - await prompter.text({ - message: "Enter Telegram bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } else { - token = String( - await prompter.text({ - message: "Enter Telegram bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - - if (token) { - if (telegramAccountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - telegram: { - ...next.telegram, - enabled: true, - botToken: token, - }, - }; - } else { - next = { - ...next, - telegram: { - ...next.telegram, - enabled: true, - accounts: { - ...next.telegram?.accounts, - [telegramAccountId]: { - ...next.telegram?.accounts?.[telegramAccountId], - enabled: - next.telegram?.accounts?.[telegramAccountId]?.enabled ?? true, - botToken: token, - }, - }, - }, - }; - } - } - - if (forceTelegramAllowFrom) { - next = await promptTelegramAllowFrom({ - cfg: next, - prompter, - accountId: telegramAccountId, - }); - } - } - - if (selection.includes("discord")) { - const discordOverride = accountOverrides.discord?.trim(); - const defaultDiscordAccountId = resolveDefaultDiscordAccountId(next); - let discordAccountId = discordOverride - ? normalizeAccountId(discordOverride) - : defaultDiscordAccountId; - if (shouldPromptAccountIds && !discordOverride) { - discordAccountId = await promptAccountId({ - cfg: next, - prompter, - label: "Discord", - currentId: discordAccountId, - listAccountIds: listDiscordAccountIds, - defaultAccountId: defaultDiscordAccountId, - }); - } - recordAccount("discord", discordAccountId); - - const resolvedAccount = resolveDiscordAccount({ - cfg: next, - accountId: discordAccountId, - }); - const accountConfigured = Boolean(resolvedAccount.token); - const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID; - const canUseEnv = allowEnv && discordEnv; - const hasConfigToken = Boolean(resolvedAccount.config.token); - - let token: string | null = null; - if (!accountConfigured) { - await noteDiscordTokenHelp(prompter); - } - if (canUseEnv && !resolvedAccount.config.token) { - const keepEnv = await prompter.confirm({ - message: "DISCORD_BOT_TOKEN detected. Use env var?", - initialValue: true, - }); - if (keepEnv) { - next = { - ...next, - discord: { - ...next.discord, - enabled: true, - }, - }; - } else { - token = String( - await prompter.text({ - message: "Enter Discord bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } else if (hasConfigToken) { - const keep = await prompter.confirm({ - message: "Discord token already configured. Keep it?", - initialValue: true, - }); - if (!keep) { - token = String( - await prompter.text({ - message: "Enter Discord bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } else { - token = String( - await prompter.text({ - message: "Enter Discord bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - - if (token) { - if (discordAccountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - discord: { - ...next.discord, - enabled: true, - token, - }, - }; - } else { - next = { - ...next, - discord: { - ...next.discord, - enabled: true, - accounts: { - ...next.discord?.accounts, - [discordAccountId]: { - ...next.discord?.accounts?.[discordAccountId], - enabled: - next.discord?.accounts?.[discordAccountId]?.enabled ?? true, - token, - }, - }, - }, - }; - } - } - } - - if (selection.includes("slack")) { - const slackOverride = accountOverrides.slack?.trim(); - const defaultSlackAccountId = resolveDefaultSlackAccountId(next); - let slackAccountId = slackOverride - ? normalizeAccountId(slackOverride) - : defaultSlackAccountId; - if (shouldPromptAccountIds && !slackOverride) { - slackAccountId = await promptAccountId({ - cfg: next, - prompter, - label: "Slack", - currentId: slackAccountId, - listAccountIds: listSlackAccountIds, - defaultAccountId: defaultSlackAccountId, - }); - } - recordAccount("slack", slackAccountId); - - const resolvedAccount = resolveSlackAccount({ - cfg: next, - accountId: slackAccountId, - }); - const accountConfigured = Boolean( - resolvedAccount.botToken && resolvedAccount.appToken, - ); - const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID; - const canUseEnv = allowEnv && slackBotEnv && slackAppEnv; - const hasConfigTokens = Boolean( - resolvedAccount.config.botToken && resolvedAccount.config.appToken, - ); - - let botToken: string | null = null; - let appToken: string | null = null; - const slackBotName = String( - await prompter.text({ - message: "Slack bot display name (used for manifest)", - initialValue: "Clawdbot", - }), - ).trim(); - if (!accountConfigured) { - await noteSlackTokenHelp(prompter, slackBotName); - } - if ( - canUseEnv && - (!resolvedAccount.config.botToken || !resolvedAccount.config.appToken) - ) { - const keepEnv = await prompter.confirm({ - message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", - initialValue: true, - }); - if (keepEnv) { - next = { - ...next, - slack: { - ...next.slack, - enabled: true, - }, - }; - } else { - botToken = String( - await prompter.text({ - message: "Enter Slack bot token (xoxb-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appToken = String( - await prompter.text({ - message: "Enter Slack app token (xapp-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } else if (hasConfigTokens) { - const keep = await prompter.confirm({ - message: "Slack tokens already configured. Keep them?", - initialValue: true, - }); - if (!keep) { - botToken = String( - await prompter.text({ - message: "Enter Slack bot token (xoxb-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appToken = String( - await prompter.text({ - message: "Enter Slack app token (xapp-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } else { - botToken = String( - await prompter.text({ - message: "Enter Slack bot token (xoxb-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - appToken = String( - await prompter.text({ - message: "Enter Slack app token (xapp-...)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - - if (botToken && appToken) { - if (slackAccountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - slack: { - ...next.slack, - enabled: true, - botToken, - appToken, - }, - }; - } else { - next = { - ...next, - slack: { - ...next.slack, - enabled: true, - accounts: { - ...next.slack?.accounts, - [slackAccountId]: { - ...next.slack?.accounts?.[slackAccountId], - enabled: - next.slack?.accounts?.[slackAccountId]?.enabled ?? true, - botToken, - appToken, - }, - }, - }, - }; - } - } - } - - if (selection.includes("signal")) { - const signalOverride = accountOverrides.signal?.trim(); - const defaultSignalAccountId = resolveDefaultSignalAccountId(next); - let signalAccountId = signalOverride - ? normalizeAccountId(signalOverride) - : defaultSignalAccountId; - if (shouldPromptAccountIds && !signalOverride) { - signalAccountId = await promptAccountId({ - cfg: next, - prompter, - label: "Signal", - currentId: signalAccountId, - listAccountIds: listSignalAccountIds, - defaultAccountId: defaultSignalAccountId, - }); - } - recordAccount("signal", signalAccountId); - - const resolvedAccount = resolveSignalAccount({ - cfg: next, - accountId: signalAccountId, - }); - const accountConfig = resolvedAccount.config; - let resolvedCliPath = accountConfig.cliPath ?? signalCliPath; - let cliDetected = await detectBinary(resolvedCliPath); - if (options?.allowSignalInstall) { - const wantsInstall = await prompter.confirm({ - message: cliDetected - ? "signal-cli detected. Reinstall/update now?" - : "signal-cli not found. Install now?", - initialValue: !cliDetected, - }); - if (wantsInstall) { - try { - const result = await installSignalCli(runtime); - if (result.ok && result.cliPath) { - cliDetected = true; - resolvedCliPath = result.cliPath; - await prompter.note( - `Installed signal-cli at ${result.cliPath}`, - "Signal", - ); - } else if (!result.ok) { - await prompter.note( - result.error ?? "signal-cli install failed.", - "Signal", - ); - } - } catch (err) { - await prompter.note( - `signal-cli install failed: ${String(err)}`, - "Signal", - ); - } - } - } - - if (!cliDetected) { - await prompter.note( - "signal-cli not found. Install it, then rerun this step or set signal.cliPath.", - "Signal", - ); - } - - let account = accountConfig.account ?? ""; - if (account) { - const keep = await prompter.confirm({ - message: `Signal account set (${account}). Keep it?`, - initialValue: true, - }); - if (!keep) account = ""; - } - - if (!account) { - account = String( - await prompter.text({ - message: "Signal bot number (E.164)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - - if (account) { - if (signalAccountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - signal: { - ...next.signal, - enabled: true, - account, - cliPath: resolvedCliPath ?? "signal-cli", - }, - }; - } else { - next = { - ...next, - signal: { - ...next.signal, - enabled: true, - accounts: { - ...next.signal?.accounts, - [signalAccountId]: { - ...next.signal?.accounts?.[signalAccountId], - enabled: - next.signal?.accounts?.[signalAccountId]?.enabled ?? true, - account, - cliPath: resolvedCliPath ?? "signal-cli", - }, - }, - }, - }; - } - } - - await prompter.note( - [ - 'Link device with: signal-cli link -n "Clawdbot"', - "Scan QR in Signal → Linked Devices", - "Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'", - `Docs: ${formatDocsLink("/signal", "signal")}`, - ].join("\n"), - "Signal next steps", - ); - } - - if (selection.includes("imessage")) { - const imessageOverride = accountOverrides.imessage?.trim(); - const defaultIMessageAccountId = resolveDefaultIMessageAccountId(next); - let imessageAccountId = imessageOverride - ? normalizeAccountId(imessageOverride) - : defaultIMessageAccountId; - if (shouldPromptAccountIds && !imessageOverride) { - imessageAccountId = await promptAccountId({ - cfg: next, - prompter, - label: "iMessage", - currentId: imessageAccountId, - listAccountIds: listIMessageAccountIds, - defaultAccountId: defaultIMessageAccountId, - }); - } - recordAccount("imessage", imessageAccountId); - - const resolvedAccount = resolveIMessageAccount({ - cfg: next, - accountId: imessageAccountId, - }); - let resolvedCliPath = resolvedAccount.config.cliPath ?? imessageCliPath; - const cliDetected = await detectBinary(resolvedCliPath); - if (!cliDetected) { - const entered = await prompter.text({ - message: "imsg CLI path", - initialValue: resolvedCliPath, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - resolvedCliPath = String(entered).trim(); - if (!resolvedCliPath) { - await prompter.note( - "imsg CLI path required to enable iMessage.", - "iMessage", - ); - } - } - - if (resolvedCliPath) { - if (imessageAccountId === DEFAULT_ACCOUNT_ID) { - next = { - ...next, - imessage: { - ...next.imessage, - enabled: true, - cliPath: resolvedCliPath, - }, - }; - } else { - next = { - ...next, - imessage: { - ...next.imessage, - enabled: true, - accounts: { - ...next.imessage?.accounts, - [imessageAccountId]: { - ...next.imessage?.accounts?.[imessageAccountId], - enabled: - next.imessage?.accounts?.[imessageAccountId]?.enabled ?? true, - cliPath: resolvedCliPath, - }, - }, - }, - }; - } - } - - await prompter.note( - [ - "This is still a work in progress.", - "Ensure Clawdbot 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")}`, - ].join("\n"), - "iMessage next steps", - ); } if (!options?.skipDmPolicyPrompt) { @@ -1643,68 +222,18 @@ export async function setupProviders( } if (options?.allowDisable) { - if (!selection.includes("telegram") && telegramConfigured) { + for (const [providerId, status] of statusByProvider) { + if (selection.includes(providerId)) continue; + if (!status.configured) continue; + const adapter = getProviderOnboardingAdapter(providerId); + if (!adapter?.disable) continue; + const meta = getChatProviderMeta(providerId); const disable = await prompter.confirm({ - message: "Disable Telegram provider?", + message: `Disable ${meta.label} provider?`, initialValue: false, }); if (disable) { - next = { - ...next, - telegram: { ...next.telegram, enabled: false }, - }; - } - } - - if (!selection.includes("discord") && discordConfigured) { - const disable = await prompter.confirm({ - message: "Disable Discord provider?", - initialValue: false, - }); - if (disable) { - next = { - ...next, - discord: { ...next.discord, enabled: false }, - }; - } - } - - if (!selection.includes("slack") && slackConfigured) { - const disable = await prompter.confirm({ - message: "Disable Slack provider?", - initialValue: false, - }); - if (disable) { - next = { - ...next, - slack: { ...next.slack, enabled: false }, - }; - } - } - - if (!selection.includes("signal") && signalConfigured) { - const disable = await prompter.confirm({ - message: "Disable Signal provider?", - initialValue: false, - }); - if (disable) { - next = { - ...next, - signal: { ...next.signal, enabled: false }, - }; - } - } - - if (!selection.includes("imessage") && imessageConfigured) { - const disable = await prompter.confirm({ - message: "Disable iMessage provider?", - initialValue: false, - }); - if (disable) { - next = { - ...next, - imessage: { ...next.imessage, enabled: false }, - }; + next = adapter.disable(next); } } } diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts new file mode 100644 index 000000000..f31e89892 --- /dev/null +++ b/src/commands/onboarding/registry.ts @@ -0,0 +1,29 @@ +import { listProviderPlugins } from "../../providers/plugins/index.js"; +import type { ProviderChoice } from "../onboard-types.js"; +import type { ProviderOnboardingAdapter } from "./types.js"; + +const PROVIDER_ONBOARDING_ADAPTERS = () => + new Map( + listProviderPlugins() + .map((plugin) => + plugin.onboarding + ? ([plugin.id as ProviderChoice, plugin.onboarding] as const) + : null, + ) + .filter( + ( + entry, + ): entry is readonly [ProviderChoice, ProviderOnboardingAdapter] => + Boolean(entry), + ), + ); + +export function getProviderOnboardingAdapter( + provider: ProviderChoice, +): ProviderOnboardingAdapter | undefined { + return PROVIDER_ONBOARDING_ADAPTERS().get(provider); +} + +export function listProviderOnboardingAdapters(): ProviderOnboardingAdapter[] { + return Array.from(PROVIDER_ONBOARDING_ADAPTERS().values()); +} diff --git a/src/commands/onboarding/types.ts b/src/commands/onboarding/types.ts new file mode 100644 index 000000000..d285cac93 --- /dev/null +++ b/src/commands/onboarding/types.ts @@ -0,0 +1 @@ +export * from "../../providers/plugins/onboarding-types.js"; diff --git a/src/commands/providers.test.ts b/src/commands/providers.test.ts index e978e167a..27b9f49a8 100644 --- a/src/commands/providers.test.ts +++ b/src/commands/providers.test.ts @@ -309,8 +309,10 @@ describe("providers command", () => { it("formats gateway provider status lines in registry order", () => { const lines = formatGatewayProvidersStatusLines({ - telegramAccounts: [{ accountId: "default", configured: true }], - whatsappAccounts: [{ accountId: "default", linked: true }], + providerAccounts: { + telegram: [{ accountId: "default", configured: true }], + whatsapp: [{ accountId: "default", linked: true }], + }, }); const telegramIndex = lines.findIndex((line) => @@ -326,14 +328,16 @@ describe("providers command", () => { it("surfaces Discord privileged intent issues in providers status output", () => { const lines = formatGatewayProvidersStatusLines({ - discordAccounts: [ - { - accountId: "default", - enabled: true, - configured: true, - application: { intents: { messageContent: "disabled" } }, - }, - ], + providerAccounts: { + discord: [ + { + accountId: "default", + enabled: true, + configured: true, + application: { intents: { messageContent: "disabled" } }, + }, + ], + }, }); expect(lines.join("\n")).toMatch(/Warnings:/); expect(lines.join("\n")).toMatch(/Message Content Intent is disabled/i); @@ -342,23 +346,25 @@ describe("providers command", () => { it("surfaces Discord permission audit issues in providers status output", () => { const lines = formatGatewayProvidersStatusLines({ - discordAccounts: [ - { - accountId: "default", - enabled: true, - configured: true, - audit: { - unresolvedChannels: 1, - channels: [ - { - channelId: "111", - ok: false, - missing: ["ViewChannel", "SendMessages"], - }, - ], + providerAccounts: { + discord: [ + { + accountId: "default", + enabled: true, + configured: true, + audit: { + unresolvedChannels: 1, + channels: [ + { + channelId: "111", + ok: false, + missing: ["ViewChannel", "SendMessages"], + }, + ], + }, }, - }, - ], + ], + }, }); expect(lines.join("\n")).toMatch(/Warnings:/); expect(lines.join("\n")).toMatch(/permission audit/i); @@ -367,14 +373,16 @@ describe("providers command", () => { it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => { const lines = formatGatewayProvidersStatusLines({ - telegramAccounts: [ - { - accountId: "default", - enabled: true, - configured: true, - allowUnmentionedGroups: true, - }, - ], + providerAccounts: { + telegram: [ + { + accountId: "default", + enabled: true, + configured: true, + allowUnmentionedGroups: true, + }, + ], + }, }); expect(lines.join("\n")).toMatch(/Warnings:/); expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i); @@ -382,25 +390,27 @@ describe("providers command", () => { it("surfaces Telegram group membership audit issues in providers status output", () => { const lines = formatGatewayProvidersStatusLines({ - telegramAccounts: [ - { - accountId: "default", - enabled: true, - configured: true, - audit: { - hasWildcardUnmentionedGroups: true, - unresolvedGroups: 1, - groups: [ - { - chatId: "-1001", - ok: false, - status: "left", - error: "not in group", - }, - ], + providerAccounts: { + telegram: [ + { + accountId: "default", + enabled: true, + configured: true, + audit: { + hasWildcardUnmentionedGroups: true, + unresolvedGroups: 1, + groups: [ + { + chatId: "-1001", + ok: false, + status: "left", + error: "not in group", + }, + ], + }, }, - }, - ], + ], + }, }); expect(lines.join("\n")).toMatch(/Warnings:/); expect(lines.join("\n")).toMatch(/membership probing is not possible/i); @@ -409,40 +419,44 @@ describe("providers command", () => { it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => { const unlinked = formatGatewayProvidersStatusLines({ - whatsappAccounts: [ - { accountId: "default", enabled: true, linked: false }, - ], + providerAccounts: { + whatsapp: [{ accountId: "default", enabled: true, linked: false }], + }, }); expect(unlinked.join("\n")).toMatch(/WhatsApp/i); expect(unlinked.join("\n")).toMatch(/Not linked/i); const disconnected = formatGatewayProvidersStatusLines({ - whatsappAccounts: [ - { - accountId: "default", - enabled: true, - linked: true, - running: true, - connected: false, - reconnectAttempts: 5, - lastError: "connection closed", - }, - ], + providerAccounts: { + whatsapp: [ + { + accountId: "default", + enabled: true, + linked: true, + running: true, + connected: false, + reconnectAttempts: 5, + lastError: "connection closed", + }, + ], + }, }); expect(disconnected.join("\n")).toMatch(/disconnected/i); }); it("surfaces Signal runtime errors in providers status output", () => { const lines = formatGatewayProvidersStatusLines({ - signalAccounts: [ - { - accountId: "default", - enabled: true, - configured: true, - running: false, - lastError: "signal-cli unreachable", - }, - ], + providerAccounts: { + signal: [ + { + accountId: "default", + enabled: true, + configured: true, + running: false, + lastError: "signal-cli unreachable", + }, + ], + }, }); expect(lines.join("\n")).toMatch(/Warnings:/); expect(lines.join("\n")).toMatch(/signal/i); @@ -451,15 +465,17 @@ describe("providers command", () => { it("surfaces iMessage runtime errors in providers status output", () => { const lines = formatGatewayProvidersStatusLines({ - imessageAccounts: [ - { - accountId: "default", - enabled: true, - configured: true, - running: false, - lastError: "imsg permission denied", - }, - ], + providerAccounts: { + imessage: [ + { + accountId: "default", + enabled: true, + configured: true, + running: false, + lastError: "imsg permission denied", + }, + ], + }, }); expect(lines.join("\n")).toMatch(/Warnings:/); expect(lines.join("\n")).toMatch(/imessage/i); diff --git a/src/commands/providers/add-mutators.ts b/src/commands/providers/add-mutators.ts index e7f946b58..2ffc1a355 100644 --- a/src/commands/providers/add-mutators.ts +++ b/src/commands/providers/add-mutators.ts @@ -1,56 +1,12 @@ import type { ClawdbotConfig } from "../../config/config.js"; -import type { ChatProviderId } from "../../providers/registry.js"; -import { - DEFAULT_ACCOUNT_ID, - normalizeAccountId, -} from "../../routing/session-key.js"; +import { getProviderPlugin } from "../../providers/plugins/index.js"; +import type { + ProviderId, + ProviderSetupInput, +} from "../../providers/plugins/types.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; -type ChatProvider = ChatProviderId; - -function providerHasAccounts(cfg: ClawdbotConfig, provider: ChatProvider) { - if (provider === "whatsapp") return true; - const base = (cfg as Record)[provider] as - | { accounts?: Record } - | undefined; - return Boolean(base?.accounts && Object.keys(base.accounts).length > 0); -} - -function shouldStoreNameInAccounts( - cfg: ClawdbotConfig, - provider: ChatProvider, - accountId: string, -): boolean { - if (provider === "whatsapp") return true; - if (accountId !== DEFAULT_ACCOUNT_ID) return true; - return providerHasAccounts(cfg, provider); -} - -function migrateBaseNameToDefaultAccount( - cfg: ClawdbotConfig, - provider: ChatProvider, -): ClawdbotConfig { - if (provider === "whatsapp") return cfg; - const base = (cfg as Record)[provider] as - | { name?: string; accounts?: Record> } - | undefined; - const baseName = base?.name?.trim(); - if (!baseName) return cfg; - const accounts: Record> = { - ...base?.accounts, - }; - const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {}; - if (!defaultAccount.name) { - accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName }; - } - const { name: _ignored, ...rest } = base ?? {}; - return { - ...cfg, - [provider]: { - ...rest, - accounts, - }, - } as ClawdbotConfig; -} +type ChatProvider = ProviderId; export function applyAccountName(params: { cfg: ClawdbotConfig; @@ -58,65 +14,12 @@ export function applyAccountName(params: { accountId: string; name?: string; }): ClawdbotConfig { - const trimmed = params.name?.trim(); - if (!trimmed) return params.cfg; const accountId = normalizeAccountId(params.accountId); - if (params.provider === "whatsapp") { - return { - ...params.cfg, - whatsapp: { - ...params.cfg.whatsapp, - accounts: { - ...params.cfg.whatsapp?.accounts, - [accountId]: { - ...params.cfg.whatsapp?.accounts?.[accountId], - name: trimmed, - }, - }, - }, - }; - } - const key = params.provider; - const useAccounts = shouldStoreNameInAccounts(params.cfg, key, accountId); - if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) { - const baseConfig = (params.cfg as Record)[key]; - const safeBase = - typeof baseConfig === "object" && baseConfig - ? (baseConfig as Record) - : {}; - return { - ...params.cfg, - [key]: { - ...safeBase, - name: trimmed, - }, - } as ClawdbotConfig; - } - const base = (params.cfg as Record)[key] as - | { name?: string; accounts?: Record> } - | undefined; - const baseAccounts: Record< - string, - Record - > = base?.accounts ?? {}; - const existingAccount = baseAccounts[accountId] ?? {}; - const baseWithoutName = - accountId === DEFAULT_ACCOUNT_ID - ? (({ name: _ignored, ...rest }) => rest)(base ?? {}) - : (base ?? {}); - return { - ...params.cfg, - [key]: { - ...baseWithoutName, - accounts: { - ...baseAccounts, - [accountId]: { - ...existingAccount, - name: trimmed, - }, - }, - }, - } as ClawdbotConfig; + const plugin = getProviderPlugin(params.provider); + const apply = plugin?.setup?.applyAccountName; + return apply + ? apply({ cfg: params.cfg, accountId, name: params.name }) + : params.cfg; } export function applyProviderAccountConfig(params: { @@ -140,205 +43,25 @@ export function applyProviderAccountConfig(params: { useEnv?: boolean; }): ClawdbotConfig { const accountId = normalizeAccountId(params.accountId); - const name = params.name?.trim() || undefined; - const namedConfig = applyAccountName({ - cfg: params.cfg, - provider: params.provider, - accountId, - name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount(namedConfig, params.provider) - : namedConfig; - - if (params.provider === "whatsapp") { - const entry = { - ...next.whatsapp?.accounts?.[accountId], - ...(params.authDir ? { authDir: params.authDir } : {}), - enabled: true, - }; - return { - ...next, - whatsapp: { - ...next.whatsapp, - accounts: { - ...next.whatsapp?.accounts, - [accountId]: entry, - }, - }, - }; - } - - if (params.provider === "telegram") { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - telegram: { - ...next.telegram, - enabled: true, - ...(params.useEnv - ? {} - : params.tokenFile - ? { tokenFile: params.tokenFile } - : params.token - ? { botToken: params.token } - : {}), - }, - }; - } - return { - ...next, - telegram: { - ...next.telegram, - enabled: true, - accounts: { - ...next.telegram?.accounts, - [accountId]: { - ...next.telegram?.accounts?.[accountId], - enabled: true, - ...(params.tokenFile - ? { tokenFile: params.tokenFile } - : params.token - ? { botToken: params.token } - : {}), - }, - }, - }, - }; - } - - if (params.provider === "discord") { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - discord: { - ...next.discord, - enabled: true, - ...(params.useEnv ? {} : params.token ? { token: params.token } : {}), - }, - }; - } - return { - ...next, - discord: { - ...next.discord, - enabled: true, - accounts: { - ...next.discord?.accounts, - [accountId]: { - ...next.discord?.accounts?.[accountId], - enabled: true, - ...(params.token ? { token: params.token } : {}), - }, - }, - }, - }; - } - - if (params.provider === "slack") { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - slack: { - ...next.slack, - enabled: true, - ...(params.useEnv - ? {} - : { - ...(params.botToken ? { botToken: params.botToken } : {}), - ...(params.appToken ? { appToken: params.appToken } : {}), - }), - }, - }; - } - return { - ...next, - slack: { - ...next.slack, - enabled: true, - accounts: { - ...next.slack?.accounts, - [accountId]: { - ...next.slack?.accounts?.[accountId], - enabled: true, - ...(params.botToken ? { botToken: params.botToken } : {}), - ...(params.appToken ? { appToken: params.appToken } : {}), - }, - }, - }, - }; - } - - if (params.provider === "signal") { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - signal: { - ...next.signal, - enabled: true, - ...(params.signalNumber ? { account: params.signalNumber } : {}), - ...(params.cliPath ? { cliPath: params.cliPath } : {}), - ...(params.httpUrl ? { httpUrl: params.httpUrl } : {}), - ...(params.httpHost ? { httpHost: params.httpHost } : {}), - ...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}), - }, - }; - } - return { - ...next, - signal: { - ...next.signal, - enabled: true, - accounts: { - ...next.signal?.accounts, - [accountId]: { - ...next.signal?.accounts?.[accountId], - enabled: true, - ...(params.signalNumber ? { account: params.signalNumber } : {}), - ...(params.cliPath ? { cliPath: params.cliPath } : {}), - ...(params.httpUrl ? { httpUrl: params.httpUrl } : {}), - ...(params.httpHost ? { httpHost: params.httpHost } : {}), - ...(params.httpPort ? { httpPort: Number(params.httpPort) } : {}), - }, - }, - }, - }; - } - - if (params.provider === "imessage") { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - imessage: { - ...next.imessage, - enabled: true, - ...(params.cliPath ? { cliPath: params.cliPath } : {}), - ...(params.dbPath ? { dbPath: params.dbPath } : {}), - ...(params.service ? { service: params.service } : {}), - ...(params.region ? { region: params.region } : {}), - }, - }; - } - return { - ...next, - imessage: { - ...next.imessage, - enabled: true, - accounts: { - ...next.imessage?.accounts, - [accountId]: { - ...next.imessage?.accounts?.[accountId], - enabled: true, - ...(params.cliPath ? { cliPath: params.cliPath } : {}), - ...(params.dbPath ? { dbPath: params.dbPath } : {}), - ...(params.service ? { service: params.service } : {}), - ...(params.region ? { region: params.region } : {}), - }, - }, - }, - }; - } - - return next; + const plugin = getProviderPlugin(params.provider); + const apply = plugin?.setup?.applyAccountConfig; + if (!apply) return params.cfg; + const input: ProviderSetupInput = { + name: params.name, + token: params.token, + tokenFile: params.tokenFile, + botToken: params.botToken, + appToken: params.appToken, + signalNumber: params.signalNumber, + cliPath: params.cliPath, + dbPath: params.dbPath, + service: params.service, + region: params.region, + authDir: params.authDir, + httpUrl: params.httpUrl, + httpHost: params.httpHost, + httpPort: params.httpPort, + useEnv: params.useEnv, + }; + return apply({ cfg: params.cfg, accountId, input }); } diff --git a/src/commands/providers/add.ts b/src/commands/providers/add.ts index 416b52b75..ce23f75c7 100644 --- a/src/commands/providers/add.ts +++ b/src/commands/providers/add.ts @@ -1,5 +1,9 @@ import { writeConfigFile } from "../../config/config.js"; -import { normalizeChatProviderId } from "../../providers/registry.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../../providers/plugins/index.js"; +import type { ProviderId } from "../../providers/plugins/types.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, @@ -75,25 +79,12 @@ export async function providersAddCommand( if (wantsNames) { for (const provider of selection) { const accountId = accountIds[provider] ?? DEFAULT_ACCOUNT_ID; - const existingName = - provider === "whatsapp" - ? nextConfig.whatsapp?.accounts?.[accountId]?.name - : provider === "telegram" - ? (nextConfig.telegram?.accounts?.[accountId]?.name ?? - nextConfig.telegram?.name) - : provider === "discord" - ? (nextConfig.discord?.accounts?.[accountId]?.name ?? - nextConfig.discord?.name) - : provider === "slack" - ? (nextConfig.slack?.accounts?.[accountId]?.name ?? - nextConfig.slack?.name) - : provider === "signal" - ? (nextConfig.signal?.accounts?.[accountId]?.name ?? - nextConfig.signal?.name) - : provider === "imessage" - ? (nextConfig.imessage?.accounts?.[accountId]?.name ?? - nextConfig.imessage?.name) - : undefined; + const plugin = getProviderPlugin(provider as ProviderId); + const account = plugin?.config.resolveAccount(nextConfig, accountId) as + | { name?: string } + | undefined; + const snapshot = plugin?.config.describeAccount?.(account, nextConfig); + const existingName = snapshot?.name ?? account?.name; const name = await prompter.text({ message: `${provider} account name (${accountId})`, initialValue: existingName, @@ -114,76 +105,48 @@ export async function providersAddCommand( return; } - const provider = normalizeChatProviderId(opts.provider); + const provider = normalizeProviderId(opts.provider); if (!provider) { runtime.error(`Unknown provider: ${String(opts.provider ?? "")}`); runtime.exit(1); return; } - const accountId = normalizeAccountId(opts.account); + const plugin = getProviderPlugin(provider); + if (!plugin?.setup?.applyAccountConfig) { + runtime.error(`Provider ${provider} does not support add.`); + runtime.exit(1); + return; + } + const accountId = + plugin.setup.resolveAccountId?.({ cfg, accountId: opts.account }) ?? + normalizeAccountId(opts.account); const useEnv = opts.useEnv === true; - - if (provider === "telegram") { - if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - runtime.error( - "TELEGRAM_BOT_TOKEN can only be used for the default account.", - ); - runtime.exit(1); - return; - } - if (!useEnv && !opts.token && !opts.tokenFile) { - runtime.error( - "Telegram requires --token or --token-file (or --use-env).", - ); - runtime.exit(1); - return; - } - } - if (provider === "discord") { - if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - runtime.error( - "DISCORD_BOT_TOKEN can only be used for the default account.", - ); - runtime.exit(1); - return; - } - if (!useEnv && !opts.token) { - runtime.error("Discord requires --token (or --use-env)."); - runtime.exit(1); - return; - } - } - if (provider === "slack") { - if (useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - runtime.error( - "Slack env tokens can only be used for the default account.", - ); - runtime.exit(1); - return; - } - if (!useEnv && (!opts.botToken || !opts.appToken)) { - runtime.error( - "Slack requires --bot-token and --app-token (or --use-env).", - ); - runtime.exit(1); - return; - } - } - if (provider === "signal") { - if ( - !opts.signalNumber && - !opts.httpUrl && - !opts.httpHost && - !opts.httpPort && - !opts.cliPath - ) { - runtime.error( - "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path.", - ); - runtime.exit(1); - return; - } + const validationError = plugin.setup.validateInput?.({ + cfg, + accountId, + input: { + name: opts.name, + token: opts.token, + tokenFile: opts.tokenFile, + botToken: opts.botToken, + appToken: opts.appToken, + signalNumber: opts.signalNumber, + cliPath: opts.cliPath, + dbPath: opts.dbPath, + service: opts.service, + region: opts.region, + authDir: opts.authDir, + httpUrl: opts.httpUrl, + httpHost: opts.httpHost, + httpPort: opts.httpPort, + useEnv, + }, + }); + if (validationError) { + runtime.error(validationError); + runtime.exit(1); + return; } const nextConfig = applyProviderAccountConfig({ diff --git a/src/commands/providers/list.ts b/src/commands/providers/list.ts index 777c6d36d..ca8a7a00e 100644 --- a/src/commands/providers/list.ts +++ b/src/commands/providers/list.ts @@ -4,44 +4,19 @@ import { loadAuthProfileStore, } from "../../agents/auth-profiles.js"; import { withProgress } from "../../cli/progress.js"; -import { - listDiscordAccountIds, - resolveDiscordAccount, -} from "../../discord/accounts.js"; -import { - listIMessageAccountIds, - resolveIMessageAccount, -} from "../../imessage/accounts.js"; import { formatUsageReportLines, loadProviderUsageSummary, } from "../../infra/provider-usage.js"; -import { resolveMSTeamsCredentials } from "../../msteams/token.js"; -import { - type ChatProviderId, - listChatProviders, -} from "../../providers/registry.js"; -import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import { listProviderPlugins } from "../../providers/plugins/index.js"; +import { buildProviderAccountSnapshot } from "../../providers/plugins/status.js"; +import type { + ProviderAccountSnapshot, + ProviderPlugin, +} from "../../providers/plugins/types.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { - listSignalAccountIds, - resolveSignalAccount, -} from "../../signal/accounts.js"; -import { - listSlackAccountIds, - resolveSlackAccount, -} from "../../slack/accounts.js"; -import { - listTelegramAccountIds, - resolveTelegramAccount, -} from "../../telegram/accounts.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; -import { - listWhatsAppAccountIds, - resolveWhatsAppAuthDir, -} from "../../web/accounts.js"; -import { webAuthExists } from "../../web/session.js"; import { formatProviderAccountLabel, requireValidConfig } from "./shared.js"; export type ProvidersListOptions = { @@ -77,6 +52,49 @@ function formatLinked(value: boolean): string { return value ? theme.success("linked") : theme.warn("not linked"); } +function shouldShowConfigured(provider: ProviderPlugin): boolean { + return provider.meta.showConfigured !== false; +} + +function formatAccountLine(params: { + provider: ProviderPlugin; + snapshot: ProviderAccountSnapshot; +}): string { + const { provider, snapshot } = params; + const label = formatProviderAccountLabel({ + provider: provider.id, + accountId: snapshot.accountId, + name: snapshot.name, + providerStyle: theme.accent, + accountStyle: theme.heading, + }); + const bits: string[] = []; + if (snapshot.linked !== undefined) { + bits.push(formatLinked(snapshot.linked)); + } + if ( + shouldShowConfigured(provider) && + typeof snapshot.configured === "boolean" + ) { + bits.push(formatConfigured(snapshot.configured)); + } + if (snapshot.tokenSource) { + bits.push(formatTokenSource(snapshot.tokenSource)); + } + if (snapshot.botTokenSource) { + bits.push(formatSource("bot", snapshot.botTokenSource)); + } + if (snapshot.appTokenSource) { + bits.push(formatSource("app", snapshot.appTokenSource)); + } + if (snapshot.baseUrl) { + bits.push(`base=${theme.muted(snapshot.baseUrl)}`); + } + if (typeof snapshot.enabled === "boolean") { + bits.push(formatEnabled(snapshot.enabled)); + } + return `- ${label}: ${bits.join(", ")}`; +} async function loadUsageWithProgress( runtime: RuntimeEnv, ): Promise> | null> { @@ -99,118 +117,7 @@ export async function providersListCommand( if (!cfg) return; const includeUsage = opts.usage !== false; - const accountIdsByProvider: Record = { - whatsapp: listWhatsAppAccountIds(cfg), - telegram: listTelegramAccountIds(cfg), - discord: listDiscordAccountIds(cfg), - slack: listSlackAccountIds(cfg), - signal: listSignalAccountIds(cfg), - imessage: listIMessageAccountIds(cfg), - msteams: [DEFAULT_ACCOUNT_ID], - }; - - const lineBuilders: Record< - ChatProviderId, - (accountId: string) => Promise - > = { - telegram: async (accountId) => { - const account = resolveTelegramAccount({ cfg, accountId }); - const label = formatProviderAccountLabel({ - provider: "telegram", - accountId, - name: account.name, - providerStyle: theme.accent, - accountStyle: theme.heading, - }); - return `- ${label}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource( - account.tokenSource, - )}, ${formatEnabled(account.enabled)}`; - }, - whatsapp: async (accountId) => { - const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); - const linked = await webAuthExists(authDir); - const name = cfg.whatsapp?.accounts?.[accountId]?.name; - const label = formatProviderAccountLabel({ - provider: "whatsapp", - accountId, - name, - providerStyle: theme.accent, - accountStyle: theme.heading, - }); - return `- ${label}: ${formatLinked(linked)}, ${formatEnabled( - cfg.whatsapp?.accounts?.[accountId]?.enabled ?? - cfg.web?.enabled ?? - true, - )}`; - }, - discord: async (accountId) => { - const account = resolveDiscordAccount({ cfg, accountId }); - const label = formatProviderAccountLabel({ - provider: "discord", - accountId, - name: account.name, - providerStyle: theme.accent, - accountStyle: theme.heading, - }); - return `- ${label}: ${formatConfigured(Boolean(account.token))}, ${formatTokenSource( - account.tokenSource, - )}, ${formatEnabled(account.enabled)}`; - }, - slack: async (accountId) => { - const account = resolveSlackAccount({ cfg, accountId }); - const configured = Boolean(account.botToken && account.appToken); - const label = formatProviderAccountLabel({ - provider: "slack", - accountId, - name: account.name, - providerStyle: theme.accent, - accountStyle: theme.heading, - }); - return `- ${label}: ${formatConfigured(configured)}, ${formatSource( - "bot", - account.botTokenSource, - )}, ${formatSource("app", account.appTokenSource)}, ${formatEnabled( - account.enabled, - )}`; - }, - signal: async (accountId) => { - const account = resolveSignalAccount({ cfg, accountId }); - const label = formatProviderAccountLabel({ - provider: "signal", - accountId, - name: account.name, - providerStyle: theme.accent, - accountStyle: theme.heading, - }); - return `- ${label}: ${formatConfigured(account.configured)}, base=${theme.muted( - account.baseUrl, - )}, ${formatEnabled(account.enabled)}`; - }, - imessage: async (accountId) => { - const account = resolveIMessageAccount({ cfg, accountId }); - const label = formatProviderAccountLabel({ - provider: "imessage", - accountId, - name: account.name, - providerStyle: theme.accent, - accountStyle: theme.heading, - }); - return `- ${label}: ${formatEnabled(account.enabled)}`; - }, - msteams: async (accountId) => { - const label = formatProviderAccountLabel({ - provider: "msteams", - accountId, - providerStyle: theme.accent, - accountStyle: theme.heading, - }); - const configured = Boolean(resolveMSTeamsCredentials(cfg.msteams)); - const enabled = cfg.msteams?.enabled !== false; - return `- ${label}: ${formatConfigured(configured)}, ${formatEnabled( - enabled, - )}`; - }, - }; + const plugins = listProviderPlugins(); const authStore = loadAuthProfileStore(); const authProfiles = Object.entries(authStore.profiles).map( @@ -225,19 +132,11 @@ export async function providersListCommand( ); if (opts.json) { const usage = includeUsage ? await loadProviderUsageSummary() : undefined; - const payload = { - chat: { - whatsapp: accountIdsByProvider.whatsapp, - telegram: accountIdsByProvider.telegram, - discord: accountIdsByProvider.discord, - slack: accountIdsByProvider.slack, - signal: accountIdsByProvider.signal, - imessage: accountIdsByProvider.imessage, - msteams: accountIdsByProvider.msteams, - }, - auth: authProfiles, - ...(usage ? { usage } : {}), - }; + const chat: Record = {}; + for (const plugin of plugins) { + chat[plugin.id] = plugin.config.listAccountIds(cfg); + } + const payload = { chat, auth: authProfiles, ...(usage ? { usage } : {}) }; runtime.log(JSON.stringify(payload, null, 2)); return; } @@ -245,12 +144,21 @@ export async function providersListCommand( const lines: string[] = []; lines.push(theme.heading("Chat providers:")); - for (const meta of listChatProviders()) { - const accounts = accountIdsByProvider[meta.id]; + for (const plugin of plugins) { + const accounts = plugin.config.listAccountIds(cfg); if (!accounts || accounts.length === 0) continue; for (const accountId of accounts) { - const line = await lineBuilders[meta.id](accountId); - lines.push(line); + const snapshot = await buildProviderAccountSnapshot({ + plugin, + cfg, + accountId, + }); + lines.push( + formatAccountLine({ + provider: plugin, + snapshot, + }), + ); } } diff --git a/src/commands/providers/logs.ts b/src/commands/providers/logs.ts index 7ea2801a8..da497fb27 100644 --- a/src/commands/providers/logs.ts +++ b/src/commands/providers/logs.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { parseLogLine } from "../../logging/parse-log-line.js"; import { getResolvedLoggerSettings } from "../../logging.js"; +import { listProviderPlugins } from "../../providers/plugins/index.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; @@ -15,13 +16,8 @@ type LogLine = ReturnType; const DEFAULT_LIMIT = 200; const MAX_BYTES = 1_000_000; -const PROVIDERS = new Set([ - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", +const PROVIDERS = new Set([ + ...listProviderPlugins().map((plugin) => plugin.id), "all", ]); diff --git a/src/commands/providers/remove.ts b/src/commands/providers/remove.ts index f6ecd25be..f9a733b1a 100644 --- a/src/commands/providers/remove.ts +++ b/src/commands/providers/remove.ts @@ -1,19 +1,15 @@ import { type ClawdbotConfig, writeConfigFile } from "../../config/config.js"; -import { listDiscordAccountIds } from "../../discord/accounts.js"; -import { listIMessageAccountIds } from "../../imessage/accounts.js"; +import { resolveProviderDefaultAccountId } from "../../providers/plugins/helpers.js"; import { - listChatProviders, - normalizeChatProviderId, -} from "../../providers/registry.js"; + getProviderPlugin, + listProviderPlugins, + normalizeProviderId, +} from "../../providers/plugins/index.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { listSignalAccountIds } from "../../signal/accounts.js"; -import { listSlackAccountIds } from "../../slack/accounts.js"; -import { listTelegramAccountIds } from "../../telegram/accounts.js"; -import { listWhatsAppAccountIds } from "../../web/accounts.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { type ChatProvider, @@ -29,22 +25,9 @@ export type ProvidersRemoveOptions = { }; function listAccountIds(cfg: ClawdbotConfig, provider: ChatProvider): string[] { - switch (provider) { - case "whatsapp": - return listWhatsAppAccountIds(cfg); - case "telegram": - return listTelegramAccountIds(cfg); - case "discord": - return listDiscordAccountIds(cfg); - case "slack": - return listSlackAccountIds(cfg); - case "signal": - return listSignalAccountIds(cfg); - case "imessage": - return listIMessageAccountIds(cfg); - case "msteams": - return [DEFAULT_ACCOUNT_ID]; - } + const plugin = getProviderPlugin(provider); + if (!plugin) return []; + return plugin.config.listAccountIds(cfg); } export async function providersRemoveCommand( @@ -57,7 +40,7 @@ export async function providersRemoveCommand( const useWizard = shouldUseWizard(params); const prompter = useWizard ? createClackPrompter() : null; - let provider = normalizeChatProviderId(opts.provider); + let provider = normalizeProviderId(opts.provider); let accountId = normalizeAccountId(opts.account); const deleteConfig = Boolean(opts.delete); @@ -65,9 +48,9 @@ export async function providersRemoveCommand( await prompter.intro("Remove provider account"); provider = (await prompter.select({ message: "Provider", - options: listChatProviders().map((meta) => ({ - value: meta.id, - label: meta.label, + options: listProviderPlugins().map((plugin) => ({ + value: plugin.id, + label: plugin.meta.label, })), })) as ChatProvider; @@ -110,139 +93,40 @@ export async function providersRemoveCommand( } } + const plugin = getProviderPlugin(provider); + if (!plugin) { + runtime.error(`Unknown provider: ${provider}`); + runtime.exit(1); + return; + } + + const resolvedAccountId = + normalizeAccountId(accountId) ?? + resolveProviderDefaultAccountId({ plugin, cfg }); + const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID; + let next = { ...cfg }; - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - - const setAccountEnabled = (key: ChatProvider, enabled: boolean) => { - if (key === "whatsapp") { - next = { - ...next, - whatsapp: { - ...next.whatsapp, - accounts: { - ...next.whatsapp?.accounts, - [accountKey]: { - ...next.whatsapp?.accounts?.[accountKey], - enabled, - }, - }, - }, - }; - return; - } - const base = (next as Record)[key] as - | { - accounts?: Record>; - enabled?: boolean; - } - | undefined; - const baseAccounts: Record< - string, - Record - > = base?.accounts ?? {}; - const existingAccount = baseAccounts[accountKey] ?? {}; - if (accountKey === DEFAULT_ACCOUNT_ID && !base?.accounts) { - next = { - ...next, - [key]: { - ...base, - enabled, - }, - } as ClawdbotConfig; - return; - } - next = { - ...next, - [key]: { - ...base, - accounts: { - ...baseAccounts, - [accountKey]: { - ...existingAccount, - enabled, - }, - }, - }, - } as ClawdbotConfig; - }; - - const deleteAccount = (key: ChatProvider) => { - if (key === "whatsapp") { - const accounts = { ...next.whatsapp?.accounts }; - delete accounts[accountKey]; - next = { - ...next, - whatsapp: { - ...next.whatsapp, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - }; - return; - } - const base = (next as Record)[key] as - | { - accounts?: Record>; - enabled?: boolean; - } - | undefined; - if (accountKey !== DEFAULT_ACCOUNT_ID) { - const accounts = { ...base?.accounts }; - delete accounts[accountKey]; - next = { - ...next, - [key]: { - ...base, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - } as ClawdbotConfig; - return; - } - if (base?.accounts && Object.keys(base.accounts).length > 0) { - const accounts = { ...base.accounts }; - delete accounts[accountKey]; - next = { - ...next, - [key]: { - ...base, - accounts: Object.keys(accounts).length ? accounts : undefined, - ...(key === "telegram" - ? { botToken: undefined, tokenFile: undefined, name: undefined } - : key === "discord" - ? { token: undefined, name: undefined } - : key === "slack" - ? { botToken: undefined, appToken: undefined, name: undefined } - : key === "signal" - ? { - account: undefined, - httpUrl: undefined, - httpHost: undefined, - httpPort: undefined, - cliPath: undefined, - name: undefined, - } - : key === "imessage" - ? { - cliPath: undefined, - dbPath: undefined, - service: undefined, - region: undefined, - name: undefined, - } - : {}), - }, - } as ClawdbotConfig; - return; - } - // No accounts map: remove entire provider section. - const clone = { ...next } as Record; - delete clone[key]; - next = clone as ClawdbotConfig; - }; - if (deleteConfig) { - deleteAccount(provider); + if (!plugin.config.deleteAccount) { + runtime.error(`Provider ${provider} does not support delete.`); + runtime.exit(1); + return; + } + next = plugin.config.deleteAccount({ + cfg: next, + accountId: resolvedAccountId, + }); } else { - setAccountEnabled(provider, false); + if (!plugin.config.setAccountEnabled) { + runtime.error(`Provider ${provider} does not support disable.`); + runtime.exit(1); + return; + } + next = plugin.config.setAccountEnabled({ + cfg: next, + accountId: resolvedAccountId, + enabled: false, + }); } await writeConfigFile(next); diff --git a/src/commands/providers/shared.ts b/src/commands/providers/shared.ts index 66fce862a..003e80ed7 100644 --- a/src/commands/providers/shared.ts +++ b/src/commands/providers/shared.ts @@ -3,13 +3,13 @@ import { readConfigFileSnapshot, } from "../../config/config.js"; import { - type ChatProviderId, - getChatProviderMeta, -} from "../../providers/registry.js"; + getProviderPlugin, + type ProviderId, +} from "../../providers/plugins/index.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -export type ChatProvider = ChatProviderId; +export type ChatProvider = ProviderId; export async function requireValidConfig( runtime: RuntimeEnv = defaultRuntime, @@ -39,8 +39,10 @@ export function formatAccountLabel(params: { return base; } -export const providerLabel = (provider: ChatProvider) => - getChatProviderMeta(provider).label; +export const providerLabel = (provider: ChatProvider) => { + const plugin = getProviderPlugin(provider); + return plugin?.meta.label ?? provider; +}; export function formatProviderAccountLabel(params: { provider: ChatProvider; diff --git a/src/commands/providers/status.ts b/src/commands/providers/status.ts index 943f0bb22..56a7f45dc 100644 --- a/src/commands/providers/status.ts +++ b/src/commands/providers/status.ts @@ -3,45 +3,15 @@ import { type ClawdbotConfig, readConfigFileSnapshot, } from "../../config/config.js"; -import { - listDiscordAccountIds, - resolveDiscordAccount, -} from "../../discord/accounts.js"; import { callGateway } from "../../gateway/call.js"; -import { - listIMessageAccountIds, - resolveIMessageAccount, -} from "../../imessage/accounts.js"; import { formatAge } from "../../infra/provider-summary.js"; import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js"; -import { resolveMSTeamsCredentials } from "../../msteams/token.js"; -import { listChatProviders } from "../../providers/registry.js"; -import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import { listProviderPlugins } from "../../providers/plugins/index.js"; +import { buildProviderAccountSnapshot } from "../../providers/plugins/status.js"; +import type { ProviderAccountSnapshot } from "../../providers/plugins/types.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { - listSignalAccountIds, - resolveSignalAccount, -} from "../../signal/accounts.js"; -import { - listSlackAccountIds, - resolveSlackAccount, -} from "../../slack/accounts.js"; -import { - listTelegramAccountIds, - resolveTelegramAccount, -} from "../../telegram/accounts.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; -import { normalizeE164 } from "../../utils.js"; -import { - listWhatsAppAccountIds, - resolveWhatsAppAccount, -} from "../../web/accounts.js"; -import { - getWebAuthAgeMs, - readWebSelfId, - webAuthExists, -} from "../../web/session.js"; import { type ChatProvider, formatProviderAccountLabel, @@ -155,33 +125,24 @@ export function formatGatewayProvidersStatusLines( return `- ${labelText}: ${bits.join(", ")}`; }); + const plugins = listProviderPlugins(); + const accountsByProvider = payload.providerAccounts as + | Record + | undefined; const accountPayloads: Partial< - Record>> - > = { - whatsapp: Array.isArray(payload.whatsappAccounts) - ? (payload.whatsappAccounts as Array>) - : undefined, - telegram: Array.isArray(payload.telegramAccounts) - ? (payload.telegramAccounts as Array>) - : undefined, - discord: Array.isArray(payload.discordAccounts) - ? (payload.discordAccounts as Array>) - : undefined, - slack: Array.isArray(payload.slackAccounts) - ? (payload.slackAccounts as Array>) - : undefined, - signal: Array.isArray(payload.signalAccounts) - ? (payload.signalAccounts as Array>) - : undefined, - imessage: Array.isArray(payload.imessageAccounts) - ? (payload.imessageAccounts as Array>) - : undefined, - }; + Record>> + > = {}; + for (const plugin of plugins) { + const raw = accountsByProvider?.[plugin.id]; + if (Array.isArray(raw)) { + accountPayloads[plugin.id] = raw as Array>; + } + } - for (const meta of listChatProviders()) { - const accounts = accountPayloads[meta.id]; + for (const plugin of plugins) { + const accounts = accountPayloads[plugin.id]; if (accounts && accounts.length > 0) { - lines.push(...accountLines(meta.id, accounts)); + lines.push(...accountLines(plugin.id as ChatProvider, accounts)); } } @@ -264,115 +225,21 @@ async function formatConfigProvidersStatusLines( return `- ${labelText}: ${bits.join(", ")}`; }); - const accounts = { - whatsapp: listWhatsAppAccountIds(cfg).map((accountId) => { - const account = resolveWhatsAppAccount({ cfg, accountId }); - const dmPolicy = account.dmPolicy ?? cfg.whatsapp?.dmPolicy ?? "pairing"; - const allowFrom = (account.allowFrom ?? cfg.whatsapp?.allowFrom ?? []) - .map(normalizeE164) - .filter(Boolean) - .slice(0, 2); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: true, - linked: undefined, - dmPolicy, - allowFrom, - }; - }), - telegram: listTelegramAccountIds(cfg).map((accountId) => { - const account = resolveTelegramAccount({ cfg, accountId }); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.token?.trim()), - tokenSource: account.tokenSource, - mode: account.config.webhookUrl ? "webhook" : "polling", - }; - }), - discord: listDiscordAccountIds(cfg).map((accountId) => { - const account = resolveDiscordAccount({ cfg, accountId }); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.token?.trim()), - tokenSource: account.tokenSource, - }; - }), - slack: listSlackAccountIds(cfg).map((accountId) => { - const account = resolveSlackAccount({ cfg, accountId }); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: - Boolean(account.botToken?.trim()) && - Boolean(account.appToken?.trim()), - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - }; - }), - signal: listSignalAccountIds(cfg).map((accountId) => { - const account = resolveSignalAccount({ cfg, accountId }); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.baseUrl, - }; - }), - imessage: listIMessageAccountIds(cfg).map((accountId) => { - const account = resolveIMessageAccount({ cfg, accountId }); - const imsgConfigured = Boolean( - account.config.cliPath || - account.config.dbPath || - account.config.allowFrom || - account.config.service || - account.config.region, - ); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: imsgConfigured, - }; - }), - msteams: [ - { - accountId: DEFAULT_ACCOUNT_ID, - enabled: cfg.msteams?.enabled !== false, - configured: Boolean(resolveMSTeamsCredentials(cfg.msteams)), - dmPolicy: cfg.msteams?.dmPolicy ?? "pairing", - allowFrom: (cfg.msteams?.allowFrom ?? []) - .map((value) => String(value ?? "").trim()) - .filter(Boolean) - .slice(0, 2), - }, - ], - } satisfies Partial>>>; - - // WhatsApp linked info (config-only best-effort). - try { - const webLinked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); - const authAge = authAgeMs === null ? "" : ` auth ${formatAge(authAgeMs)}`; - const { e164 } = readWebSelfId(); - lines.push( - `WhatsApp: ${webLinked ? "linked" : "not linked"}${e164 ? ` ${e164}` : ""}${webLinked ? authAge : ""}`, - ); - } catch { - // ignore - } - - for (const meta of listChatProviders()) { - const providerAccounts = accounts[meta.id]; - if (providerAccounts && providerAccounts.length > 0) { - lines.push(...accountLines(meta.id, providerAccounts)); + const plugins = listProviderPlugins(); + for (const plugin of plugins) { + const accountIds = plugin.config.listAccountIds(cfg); + if (!accountIds.length) continue; + const snapshots: ProviderAccountSnapshot[] = []; + for (const accountId of accountIds) { + const snapshot = await buildProviderAccountSnapshot({ + plugin, + cfg, + accountId, + }); + snapshots.push(snapshot); + } + if (snapshots.length > 0) { + lines.push(...accountLines(plugin.id as ChatProvider, snapshots)); } } diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index 4d5dae3b8..17e3af245 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -11,6 +11,7 @@ import { resolveMainSessionKey, resolveStorePath, } from "../config/sessions.js"; +import { normalizeProviderId } from "../providers/registry.js"; import { buildAgentMainSessionKey, normalizeAgentId, @@ -21,6 +22,7 @@ import { import type { RuntimeEnv } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; +import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js"; type SandboxExplainOptions = { session?: string; @@ -30,16 +32,6 @@ type SandboxExplainOptions = { const SANDBOX_DOCS_URL = "https://docs.clawd.bot/sandbox"; -const KNOWN_PROVIDER_KEYS = new Set([ - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "webchat", -]); - function normalizeExplainSessionKey(params: { cfg: ClawdbotConfig; agentId: string; @@ -73,9 +65,9 @@ function inferProviderFromSessionKey(params: { const configuredMainKey = normalizeMainKey(params.cfg.session?.mainKey); if (parts[0] === configuredMainKey) return undefined; const candidate = parts[0]?.trim().toLowerCase(); - return candidate && KNOWN_PROVIDER_KEYS.has(candidate) - ? candidate - : undefined; + if (!candidate) return undefined; + if (candidate === INTERNAL_MESSAGE_PROVIDER) return INTERNAL_MESSAGE_PROVIDER; + return normalizeProviderId(candidate) ?? undefined; } function resolveActiveProvider(params: { @@ -96,43 +88,15 @@ function resolveActiveProvider(params: { ) .trim() .toLowerCase(); - if (candidate && KNOWN_PROVIDER_KEYS.has(candidate)) return candidate; + if (candidate === INTERNAL_MESSAGE_PROVIDER) return INTERNAL_MESSAGE_PROVIDER; + const normalized = normalizeProviderId(candidate); + if (normalized) return normalized; return inferProviderFromSessionKey({ cfg: params.cfg, sessionKey: params.sessionKey, }); } -function resolveElevatedAllowListForProvider(params: { - provider: string; - allowFrom?: Record | undefined>; - discordFallback?: Array; -}): Array | undefined { - switch (params.provider) { - case "whatsapp": - return params.allowFrom?.whatsapp; - case "telegram": - return params.allowFrom?.telegram; - case "discord": { - const hasExplicit = Boolean( - params.allowFrom && Object.hasOwn(params.allowFrom, "discord"), - ); - if (hasExplicit) return params.allowFrom?.discord; - return params.discordFallback; - } - case "slack": - return params.allowFrom?.slack; - case "signal": - return params.allowFrom?.signal; - case "imessage": - return params.allowFrom?.imessage; - case "webchat": - return params.allowFrom?.webchat; - default: - return undefined; - } -} - export async function sandboxExplainCommand( opts: SandboxExplainOptions, runtime: RuntimeEnv, @@ -182,26 +146,11 @@ export async function sandboxExplainCommand( const elevatedAgentEnabled = elevatedAgent?.enabled !== false; const elevatedEnabled = elevatedGlobalEnabled && elevatedAgentEnabled; - const discordFallback = - provider === "discord" ? cfg.discord?.dm?.allowFrom : undefined; const globalAllow = provider - ? resolveElevatedAllowListForProvider({ - provider, - allowFrom: elevatedGlobal?.allowFrom as unknown as Record< - string, - Array | undefined - >, - discordFallback, - }) + ? elevatedGlobal?.allowFrom?.[provider] : undefined; const agentAllow = provider - ? resolveElevatedAllowListForProvider({ - provider, - allowFrom: elevatedAgent?.allowFrom as unknown as Record< - string, - Array | undefined - >, - }) + ? elevatedAgent?.allowFrom?.[provider] : undefined; const allowTokens = (values?: Array) => @@ -233,10 +182,7 @@ export async function sandboxExplainCommand( if (provider && globalAllowTokens.length === 0) { elevatedFailures.push({ gate: "allowFrom", - key: - provider === "discord" && discordFallback - ? "tools.elevated.allowFrom.discord (or discord.dm.allowFrom fallback)" - : `tools.elevated.allowFrom.${provider}`, + key: `tools.elevated.allowFrom.${provider}`, }); } if (provider && elevatedAgent?.allowFrom && agentAllowTokens.length === 0) { diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 11b7554c1..9073725c1 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -425,6 +425,7 @@ export async function statusAllCommand( }); const providerRows = providers.rows.map((row) => ({ + providerId: row.id, Provider: row.provider, Enabled: row.enabled ? ok("ON") : muted("OFF"), State: @@ -447,27 +448,8 @@ export async function statusAllCommand( } return map; })(); - const providerKeyForLabel = (label: string) => { - switch (label) { - case "WhatsApp": - return "whatsapp"; - case "Telegram": - return "telegram"; - case "Discord": - return "discord"; - case "Slack": - return "slack"; - case "Signal": - return "signal"; - case "iMessage": - return "imessage"; - default: - return label.toLowerCase(); - } - }; const providerRowsWithIssues = providerRows.map((row) => { - const providerKey = providerKeyForLabel(row.Provider); - const issues = providerIssuesByProvider.get(providerKey) ?? []; + const issues = providerIssuesByProvider.get(row.providerId) ?? []; if (issues.length === 0) return row; const issue = issues[0]; const suffix = ` · ${warn(`gateway: ${String(issue.message).slice(0, 90)}`)}`; diff --git a/src/commands/status-all/providers.ts b/src/commands/status-all/providers.ts index f32fb80a9..6bc565fdc 100644 --- a/src/commands/status-all/providers.ts +++ b/src/commands/status-all/providers.ts @@ -1,46 +1,35 @@ import crypto from "node:crypto"; import fs from "node:fs"; + import type { ClawdbotConfig } from "../../config/config.js"; -import { - listDiscordAccountIds, - resolveDiscordAccount, -} from "../../discord/accounts.js"; -import { - listIMessageAccountIds, - resolveIMessageAccount, -} from "../../imessage/accounts.js"; -import { resolveMSTeamsCredentials } from "../../msteams/token.js"; -import { - listSignalAccountIds, - resolveSignalAccount, -} from "../../signal/accounts.js"; -import { - listSlackAccountIds, - resolveSlackAccount, -} from "../../slack/accounts.js"; -import { - listTelegramAccountIds, - resolveTelegramAccount, -} from "../../telegram/accounts.js"; -import { normalizeE164 } from "../../utils.js"; -import { - listWhatsAppAccountIds, - resolveWhatsAppAccount, -} from "../../web/accounts.js"; -import { - getWebAuthAgeMs, - readWebSelfId, - webAuthExists, -} from "../../web/session.js"; +import { resolveProviderDefaultAccountId } from "../../providers/plugins/helpers.js"; +import { listProviderPlugins } from "../../providers/plugins/index.js"; +import type { + ProviderAccountSnapshot, + ProviderId, + ProviderPlugin, +} from "../../providers/plugins/types.js"; import { formatAge } from "./format.js"; export type ProviderRow = { + id: ProviderId; provider: string; enabled: boolean; state: "ok" | "setup" | "warn" | "off"; detail: string; }; +type ProviderAccountRow = { + accountId: string; + account: unknown; + enabled: boolean; + configured: boolean; + snapshot: ProviderAccountSnapshot; +}; + +const asRecord = (value: unknown): Record => + value && typeof value === "object" ? (value as Record) : {}; + function summarizeSources(sources: Array): { label: string; parts: string[]; @@ -85,6 +74,242 @@ function formatTokenHint( return `${head}…${tail} · len ${t.length}`; } +const formatAccountLabel = (params: { accountId: string; name?: string }) => { + const base = params.accountId || "default"; + if (params.name?.trim()) return `${base} (${params.name.trim()})`; + return base; +}; + +const resolveAccountEnabled = ( + plugin: ProviderPlugin, + account: unknown, + cfg: ClawdbotConfig, +): boolean => { + if (plugin.config.isEnabled) return plugin.config.isEnabled(account, cfg); + const enabled = asRecord(account).enabled; + return enabled !== false; +}; + +const resolveAccountConfigured = async ( + plugin: ProviderPlugin, + account: unknown, + cfg: ClawdbotConfig, +): Promise => { + if (plugin.config.isConfigured) { + return await plugin.config.isConfigured(account, cfg); + } + const configured = asRecord(account).configured; + return configured !== false; +}; + +const buildAccountSnapshot = (params: { + plugin: ProviderPlugin; + account: unknown; + cfg: ClawdbotConfig; + accountId: string; + enabled: boolean; + configured: boolean; +}): ProviderAccountSnapshot => { + const described = params.plugin.config.describeAccount?.( + params.account, + params.cfg, + ); + return { + enabled: params.enabled, + configured: params.configured, + ...described, + accountId: params.accountId, + }; +}; + +const formatAllowFrom = (params: { + plugin: ProviderPlugin; + cfg: ClawdbotConfig; + accountId?: string | null; + allowFrom: Array; +}) => { + if (params.plugin.config.formatAllowFrom) { + return params.plugin.config.formatAllowFrom({ + cfg: params.cfg, + accountId: params.accountId, + allowFrom: params.allowFrom, + }); + } + return params.allowFrom.map((entry) => String(entry).trim()).filter(Boolean); +}; + +const buildAccountNotes = (params: { + plugin: ProviderPlugin; + cfg: ClawdbotConfig; + entry: ProviderAccountRow; +}) => { + const { plugin, cfg, entry } = params; + const notes: string[] = []; + const snapshot = entry.snapshot; + if (snapshot.enabled === false) notes.push("disabled"); + if (snapshot.dmPolicy) notes.push(`dm:${snapshot.dmPolicy}`); + if (snapshot.tokenSource && snapshot.tokenSource !== "none") { + notes.push(`token:${snapshot.tokenSource}`); + } + if (snapshot.botTokenSource && snapshot.botTokenSource !== "none") { + notes.push(`bot:${snapshot.botTokenSource}`); + } + if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") { + notes.push(`app:${snapshot.appTokenSource}`); + } + if (snapshot.baseUrl) notes.push(snapshot.baseUrl); + if (snapshot.port != null) notes.push(`port:${snapshot.port}`); + if (snapshot.cliPath) notes.push(`cli:${snapshot.cliPath}`); + if (snapshot.dbPath) notes.push(`db:${snapshot.dbPath}`); + + const allowFrom = + plugin.config.resolveAllowFrom?.({ cfg, accountId: snapshot.accountId }) ?? + snapshot.allowFrom; + if (allowFrom?.length) { + const formatted = formatAllowFrom({ + plugin, + cfg, + accountId: snapshot.accountId, + allowFrom, + }).slice(0, 3); + if (formatted.length > 0) notes.push(`allow:${formatted.join(",")}`); + } + + return notes; +}; + +function resolveLinkFields(summary: unknown): { + linked: boolean | null; + authAgeMs: number | null; + selfE164: string | null; +} { + const rec = asRecord(summary); + const linked = typeof rec.linked === "boolean" ? rec.linked : null; + const authAgeMs = typeof rec.authAgeMs === "number" ? rec.authAgeMs : null; + const self = asRecord(rec.self); + const selfE164 = + typeof self.e164 === "string" && self.e164.trim() ? self.e164.trim() : null; + return { linked, authAgeMs, selfE164 }; +} + +function collectMissingPaths(accounts: ProviderAccountRow[]): string[] { + const missing: string[] = []; + for (const entry of accounts) { + const accountRec = asRecord(entry.account); + const snapshotRec = asRecord(entry.snapshot); + for (const key of [ + "tokenFile", + "botTokenFile", + "appTokenFile", + "cliPath", + "dbPath", + "authDir", + ]) { + const raw = + (accountRec[key] as string | undefined) ?? + (snapshotRec[key] as string | undefined); + const ok = existsSyncMaybe(raw); + if (ok === false) missing.push(String(raw)); + } + } + return missing; +} + +function summarizeTokenConfig(params: { + plugin: ProviderPlugin; + cfg: ClawdbotConfig; + accounts: ProviderAccountRow[]; + showSecrets: boolean; +}): { state: "ok" | "setup" | "warn" | null; detail: string | null } { + const enabled = params.accounts.filter((a) => a.enabled); + if (enabled.length === 0) return { state: null, detail: null }; + + const accountRecs = enabled.map((a) => asRecord(a.account)); + const hasBotOrAppTokenFields = accountRecs.some( + (r) => "botToken" in r || "appToken" in r, + ); + const hasTokenField = accountRecs.some((r) => "token" in r); + + if (!hasBotOrAppTokenFields && !hasTokenField) { + return { state: null, detail: null }; + } + + if (hasBotOrAppTokenFields) { + const ready = enabled.filter((a) => { + const rec = asRecord(a.account); + const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : ""; + const app = typeof rec.appToken === "string" ? rec.appToken.trim() : ""; + return Boolean(bot) && Boolean(app); + }); + const partial = enabled.filter((a) => { + const rec = asRecord(a.account); + const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : ""; + const app = typeof rec.appToken === "string" ? rec.appToken.trim() : ""; + const hasBot = Boolean(bot); + const hasApp = Boolean(app); + return (hasBot && !hasApp) || (!hasBot && hasApp); + }); + + if (partial.length > 0) { + return { + state: "warn", + detail: `partial tokens (need bot+app) · accounts ${partial.length}`, + }; + } + + if (ready.length === 0) { + return { state: "setup", detail: "no tokens (need bot+app)" }; + } + + const botSources = summarizeSources( + ready.map((a) => a.snapshot.botTokenSource ?? "none"), + ); + const appSources = summarizeSources( + ready.map((a) => a.snapshot.appTokenSource ?? "none"), + ); + + const sample = ready[0]?.account ? asRecord(ready[0].account) : {}; + const botToken = typeof sample.botToken === "string" ? sample.botToken : ""; + const appToken = typeof sample.appToken === "string" ? sample.appToken : ""; + const botHint = botToken.trim() + ? formatTokenHint(botToken, { showSecrets: params.showSecrets }) + : ""; + const appHint = appToken.trim() + ? formatTokenHint(appToken, { showSecrets: params.showSecrets }) + : ""; + + const hint = + botHint || appHint + ? ` (bot ${botHint || "?"}, app ${appHint || "?"})` + : ""; + return { + state: "ok", + detail: `tokens ok (bot ${botSources.label}, app ${appSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`, + }; + } + + const ready = enabled.filter((a) => { + const rec = asRecord(a.account); + return typeof rec.token === "string" ? Boolean(rec.token.trim()) : false; + }); + if (ready.length === 0) { + return { state: "setup", detail: "no token" }; + } + + const sources = summarizeSources(ready.map((a) => a.snapshot.tokenSource)); + const sample = ready[0]?.account ? asRecord(ready[0].account) : {}; + const token = typeof sample.token === "string" ? sample.token : ""; + const hint = token.trim() + ? ` (${formatTokenHint(token, { showSecrets: params.showSecrets })})` + : ""; + return { + state: "ok", + detail: `token ${sources.label}${hint} · accounts ${ready.length}/${enabled.length || 1}`, + }; +} + +// `status --all` providers table. +// Keep this generic: provider-specific rules belong in the provider plugin. export async function buildProvidersTable( cfg: ClawdbotConfig, opts?: { showSecrets?: boolean }, @@ -104,266 +329,141 @@ export async function buildProvidersTable( rows: Array>; }> = []; - // WhatsApp - const waEnabled = cfg.web?.enabled !== false; - const waLinked = waEnabled ? await webAuthExists().catch(() => false) : false; - const waAuthAgeMs = waLinked ? getWebAuthAgeMs() : null; - const waSelf = waLinked ? readWebSelfId().e164 : undefined; - const waAccounts = waLinked - ? listWhatsAppAccountIds(cfg).map((accountId) => - resolveWhatsAppAccount({ cfg, accountId }), - ) - : []; - rows.push({ - provider: "WhatsApp", - enabled: waEnabled, - state: !waEnabled ? "off" : waLinked ? "ok" : "setup", - detail: waEnabled - ? waLinked - ? `linked${waSelf ? ` ${waSelf}` : ""}${waAuthAgeMs ? ` · auth ${formatAge(waAuthAgeMs)}` : ""} · accounts ${waAccounts.length || 1}` - : "not linked (run clawdbot login)" - : "disabled", - }); - if (waLinked) { - const waRows = - waAccounts.length > 0 ? waAccounts : [resolveWhatsAppAccount({ cfg })]; - details.push({ - title: "WhatsApp accounts", - columns: ["Account", "Status", "Notes"], - rows: waRows.map((account) => { - const allowFrom = (account.allowFrom ?? cfg.whatsapp?.allowFrom ?? []) - .map(normalizeE164) - .filter(Boolean) - .slice(0, 3); - const dmPolicy = - account.dmPolicy ?? cfg.whatsapp?.dmPolicy ?? "pairing"; - const notes: string[] = []; - if (!account.enabled) notes.push("disabled"); - if (account.selfChatMode) notes.push("self-chat"); - notes.push(`dm:${dmPolicy}`); - if (allowFrom.length) notes.push(`allow:${allowFrom.join(",")}`); - return { - Account: account.name?.trim() - ? `${account.accountId} (${account.name.trim()})` - : account.accountId, - Status: account.enabled ? "OK" : "OFF", - Notes: notes.join(" · "), - }; - }), + for (const plugin of listProviderPlugins()) { + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveProviderDefaultAccountId({ + plugin, + cfg, + accountIds, }); - } + const resolvedAccountIds = + accountIds.length > 0 ? accountIds : [defaultAccountId]; - // Telegram - const tgEnabled = cfg.telegram?.enabled !== false; - const tgAccounts = listTelegramAccountIds(cfg).map((accountId) => - resolveTelegramAccount({ cfg, accountId }), - ); - const tgEnabledAccounts = tgAccounts.filter((a) => a.enabled); - const tgTokenAccounts = tgEnabledAccounts.filter((a) => a.token?.trim()); - const tgSources = summarizeSources(tgTokenAccounts.map((a) => a.tokenSource)); - const tgSampleToken = tgTokenAccounts[0]?.token?.trim() || ""; - const tgTokenHint = tgSampleToken - ? formatTokenHint(tgSampleToken, { showSecrets }) - : ""; - const tgMissingFiles: string[] = []; - const tgGlobalTokenFileExists = existsSyncMaybe(cfg.telegram?.tokenFile); - if ( - tgEnabled && - cfg.telegram?.tokenFile?.trim() && - tgGlobalTokenFileExists === false - ) { - tgMissingFiles.push("telegram.tokenFile"); - } - for (const accountId of listTelegramAccountIds(cfg)) { - const tokenFile = - cfg.telegram?.accounts?.[accountId]?.tokenFile?.trim() || ""; - const ok = existsSyncMaybe(tokenFile); - if (tgEnabled && tokenFile && ok === false) { - tgMissingFiles.push(`telegram.accounts.${accountId}.tokenFile`); + const accounts: ProviderAccountRow[] = []; + for (const accountId of resolvedAccountIds) { + const account = plugin.config.resolveAccount(cfg, accountId); + const enabled = resolveAccountEnabled(plugin, account, cfg); + const configured = await resolveAccountConfigured(plugin, account, cfg); + const snapshot = buildAccountSnapshot({ + plugin, + cfg, + accountId, + account, + enabled, + configured, + }); + accounts.push({ accountId, account, enabled, configured, snapshot }); + } + + const anyEnabled = accounts.some((a) => a.enabled); + const enabledAccounts = accounts.filter((a) => a.enabled); + const configuredAccounts = enabledAccounts.filter((a) => a.configured); + const defaultEntry = + accounts.find((a) => a.accountId === defaultAccountId) ?? accounts[0]; + + const summary = plugin.status?.buildProviderSummary + ? await plugin.status.buildProviderSummary({ + account: defaultEntry?.account ?? {}, + cfg, + defaultAccountId, + snapshot: + defaultEntry?.snapshot ?? + ({ accountId: defaultAccountId } as ProviderAccountSnapshot), + }) + : undefined; + + const link = resolveLinkFields(summary); + const missingPaths = collectMissingPaths(enabledAccounts); + const tokenSummary = summarizeTokenConfig({ + plugin, + cfg, + accounts, + showSecrets, + }); + + const issues = plugin.status?.collectStatusIssues + ? plugin.status.collectStatusIssues(accounts.map((a) => a.snapshot)) + : []; + + const label = plugin.meta.label ?? plugin.id; + + const state = (() => { + if (!anyEnabled) return "off"; + if (missingPaths.length > 0) return "warn"; + if (issues.length > 0) return "warn"; + if (link.linked === false) return "setup"; + if (tokenSummary.state) return tokenSummary.state; + if (link.linked === true) return "ok"; + if (configuredAccounts.length > 0) return "ok"; + return "setup"; + })(); + + const detail = (() => { + if (!anyEnabled) { + if (!defaultEntry) return "disabled"; + return ( + plugin.config.disabledReason?.(defaultEntry.account, cfg) ?? + "disabled" + ); + } + if (missingPaths.length > 0) return `missing file (${missingPaths[0]})`; + if (issues.length > 0) return issues[0]?.message ?? "misconfigured"; + + if (link.linked !== null) { + const base = link.linked ? "linked" : "not linked"; + const extra: string[] = []; + if (link.linked && link.selfE164) extra.push(link.selfE164); + if (link.linked && link.authAgeMs != null && link.authAgeMs >= 0) { + extra.push(`auth ${formatAge(link.authAgeMs)}`); + } + if (accounts.length > 1 || plugin.meta.forceAccountBinding) { + extra.push(`accounts ${accounts.length || 1}`); + } + return extra.length > 0 ? `${base} · ${extra.join(" · ")}` : base; + } + + if (tokenSummary.detail) return tokenSummary.detail; + + if (configuredAccounts.length > 0) { + const head = "configured"; + if (accounts.length <= 1 && !plugin.meta.forceAccountBinding) + return head; + return `${head} · accounts ${configuredAccounts.length}/${enabledAccounts.length || 1}`; + } + + const reason = + defaultEntry && plugin.config.unconfiguredReason + ? plugin.config.unconfiguredReason(defaultEntry.account, cfg) + : null; + return reason ?? "not configured"; + })(); + + rows.push({ + id: plugin.id, + provider: label, + enabled: anyEnabled, + state, + detail, + }); + + if (configuredAccounts.length > 0) { + details.push({ + title: `${label} accounts`, + columns: ["Account", "Status", "Notes"], + rows: configuredAccounts.map((entry) => { + const notes = buildAccountNotes({ plugin, cfg, entry }); + return { + Account: formatAccountLabel({ + accountId: entry.accountId, + name: entry.snapshot.name, + }), + Status: entry.enabled !== false ? "OK" : "WARN", + Notes: notes.join(" · "), + }; + }), + }); } } - const tgMisconfigured = tgMissingFiles.length > 0; - rows.push({ - provider: "Telegram", - enabled: tgEnabled, - state: !tgEnabled - ? "off" - : tgMisconfigured - ? "warn" - : tgTokenAccounts.length > 0 - ? "ok" - : "setup", - detail: tgEnabled - ? tgMisconfigured - ? `token file missing (${tgMissingFiles[0]})` - : tgTokenAccounts.length > 0 - ? `bot token ${tgSources.label}${tgTokenHint ? ` (${tgTokenHint})` : ""} · accounts ${tgTokenAccounts.length}/${tgEnabledAccounts.length || 1}` - : "no bot token (TELEGRAM_BOT_TOKEN / telegram.botToken)" - : "disabled", - }); - - // Discord - const dcEnabled = cfg.discord?.enabled !== false; - const dcAccounts = listDiscordAccountIds(cfg).map((accountId) => - resolveDiscordAccount({ cfg, accountId }), - ); - const dcEnabledAccounts = dcAccounts.filter((a) => a.enabled); - const dcTokenAccounts = dcEnabledAccounts.filter((a) => a.token?.trim()); - const dcSources = summarizeSources(dcTokenAccounts.map((a) => a.tokenSource)); - const dcSampleToken = dcTokenAccounts[0]?.token?.trim() || ""; - const dcTokenHint = dcSampleToken - ? formatTokenHint(dcSampleToken, { showSecrets }) - : ""; - rows.push({ - provider: "Discord", - enabled: dcEnabled, - state: !dcEnabled ? "off" : dcTokenAccounts.length > 0 ? "ok" : "setup", - detail: dcEnabled - ? dcTokenAccounts.length > 0 - ? `bot token ${dcSources.label}${dcTokenHint ? ` (${dcTokenHint})` : ""} · accounts ${dcTokenAccounts.length}/${dcEnabledAccounts.length || 1}` - : "no bot token (DISCORD_BOT_TOKEN / discord.token)" - : "disabled", - }); - - // Slack - const slEnabled = cfg.slack?.enabled !== false; - const slAccounts = listSlackAccountIds(cfg).map((accountId) => - resolveSlackAccount({ cfg, accountId }), - ); - const slEnabledAccounts = slAccounts.filter((a) => a.enabled); - const slReady = slEnabledAccounts.filter( - (a) => Boolean(a.botToken?.trim()) && Boolean(a.appToken?.trim()), - ); - const slPartial = slEnabledAccounts.filter( - (a) => - (a.botToken?.trim() && !a.appToken?.trim()) || - (!a.botToken?.trim() && a.appToken?.trim()), - ); - const slHasAnyToken = slEnabledAccounts.some( - (a) => Boolean(a.botToken?.trim()) || Boolean(a.appToken?.trim()), - ); - const slBotSources = summarizeSources( - slReady.map((a) => a.botTokenSource ?? "none"), - ); - const slAppSources = summarizeSources( - slReady.map((a) => a.appTokenSource ?? "none"), - ); - const slSample = slReady[0] ?? null; - const slBotHint = - slSample?.botToken?.trim() && slSample.botTokenSource !== "none" - ? formatTokenHint(slSample.botToken, { showSecrets }) - : ""; - const slAppHint = - slSample?.appToken?.trim() && slSample.appTokenSource !== "none" - ? formatTokenHint(slSample.appToken, { showSecrets }) - : ""; - rows.push({ - provider: "Slack", - enabled: slEnabled, - state: !slEnabled - ? "off" - : slPartial.length > 0 - ? "warn" - : slReady.length > 0 - ? "ok" - : "setup", - detail: slEnabled - ? slPartial.length > 0 - ? `partial tokens (need bot+app) · accounts ${slPartial.length}` - : slReady.length > 0 - ? `tokens ok (bot ${slBotSources.label}${slBotHint ? ` ${slBotHint}` : ""}, app ${slAppSources.label}${slAppHint ? ` ${slAppHint}` : ""}) · accounts ${slReady.length}/${slEnabledAccounts.length || 1}` - : slHasAnyToken - ? "tokens incomplete (need bot+app)" - : "no tokens (SLACK_BOT_TOKEN + SLACK_APP_TOKEN)" - : "disabled", - }); - - // Signal - const siEnabled = cfg.signal?.enabled !== false; - const siAccounts = listSignalAccountIds(cfg).map((accountId) => - resolveSignalAccount({ cfg, accountId }), - ); - const siEnabledAccounts = siAccounts.filter((a) => a.enabled); - const siConfiguredAccounts = siEnabledAccounts.filter((a) => a.configured); - const siSample = siConfiguredAccounts[0] ?? siEnabledAccounts[0] ?? null; - const siBaseUrl = siSample?.baseUrl?.trim() ? siSample.baseUrl.trim() : ""; - rows.push({ - provider: "Signal", - enabled: siEnabled, - state: !siEnabled - ? "off" - : siConfiguredAccounts.length > 0 - ? "ok" - : "setup", - detail: siEnabled - ? siConfiguredAccounts.length > 0 - ? `configured${siBaseUrl ? ` · baseUrl ${siBaseUrl}` : ""} · accounts ${siConfiguredAccounts.length}/${siEnabledAccounts.length || 1}` - : "default config (no overrides)" - : "disabled", - }); - - // iMessage - const imEnabled = cfg.imessage?.enabled !== false; - const imAccounts = listIMessageAccountIds(cfg).map((accountId) => - resolveIMessageAccount({ cfg, accountId }), - ); - const imEnabledAccounts = imAccounts.filter((a) => a.enabled); - const imConfiguredAccounts = imEnabledAccounts.filter((a) => a.configured); - const imSample = imEnabledAccounts[0] ?? null; - const imCliPath = imSample?.config?.cliPath?.trim() || ""; - const imDbPath = imSample?.config?.dbPath?.trim() || ""; - rows.push({ - provider: "iMessage", - enabled: imEnabled, - state: !imEnabled - ? "off" - : imConfiguredAccounts.length > 0 - ? "ok" - : "setup", - detail: imEnabled - ? imConfiguredAccounts.length > 0 - ? `configured${imCliPath ? ` · cliPath ${imCliPath}` : ""}${imDbPath ? ` · dbPath ${imDbPath}` : ""} · accounts ${imConfiguredAccounts.length}/${imEnabledAccounts.length || 1}` - : "default config (no overrides)" - : "disabled", - }); - - // MS Teams - const msEnabled = cfg.msteams?.enabled !== false; - const msCreds = resolveMSTeamsCredentials(cfg.msteams); - const msAppId = - cfg.msteams?.appId?.trim() || process.env.MSTEAMS_APP_ID?.trim(); - const msAppPassword = - cfg.msteams?.appPassword?.trim() || - process.env.MSTEAMS_APP_PASSWORD?.trim(); - const msTenantId = - cfg.msteams?.tenantId?.trim() || process.env.MSTEAMS_TENANT_ID?.trim(); - const msMissing = [ - !msAppId ? "appId" : null, - !msAppPassword ? "appPassword" : null, - !msTenantId ? "tenantId" : null, - ].filter(Boolean) as string[]; - const msAnyPresent = Boolean(msAppId || msAppPassword || msTenantId); - const msPasswordHint = msAppPassword - ? formatTokenHint(msAppPassword, { showSecrets }) - : ""; - rows.push({ - provider: "MS Teams", - enabled: msEnabled, - state: !msEnabled - ? "off" - : msCreds - ? "ok" - : msAnyPresent - ? "warn" - : "setup", - detail: msEnabled - ? msCreds - ? `credentials set${msPasswordHint ? ` (password ${msPasswordHint})` : ""}` - : msAnyPresent - ? `credentials incomplete (missing ${msMissing.join(", ")})` - : "no credentials (MSTEAMS_APP_ID / _PASSWORD / _TENANT_ID)" - : "disabled", - }); return { rows, diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index a2b19271b..05bcd8ce6 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -39,6 +39,88 @@ vi.mock("../config/sessions.js", () => ({ resolveMainSessionKey: mocks.resolveMainSessionKey, resolveStorePath: mocks.resolveStorePath, })); +vi.mock("../providers/plugins/index.js", () => ({ + listProviderPlugins: () => + [ + { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/platforms/whatsapp", + blurb: "mock", + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + status: { + buildProviderSummary: async () => ({ linked: true, authAgeMs: 5000 }), + }, + }, + { + id: "signal", + meta: { + id: "signal", + label: "Signal", + selectionLabel: "Signal", + docsPath: "/platforms/signal", + blurb: "mock", + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + status: { + collectStatusIssues: (accounts: Array>) => + accounts + .filter( + (account) => + typeof account.lastError === "string" && account.lastError, + ) + .map((account) => ({ + provider: "signal", + accountId: + typeof account.accountId === "string" + ? account.accountId + : "default", + message: `Provider error: ${String(account.lastError)}`, + })), + }, + }, + { + id: "imessage", + meta: { + id: "imessage", + label: "iMessage", + selectionLabel: "iMessage", + docsPath: "/platforms/mac", + blurb: "mock", + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + status: { + collectStatusIssues: (accounts: Array>) => + accounts + .filter( + (account) => + typeof account.lastError === "string" && account.lastError, + ) + .map((account) => ({ + provider: "imessage", + accountId: + typeof account.accountId === "string" + ? account.accountId + : "default", + message: `Provider error: ${String(account.lastError)}`, + })), + }, + }, + ] as unknown, +})); vi.mock("../web/session.js", () => ({ webAuthExists: mocks.webAuthExists, getWebAuthAgeMs: mocks.getWebAuthAgeMs, @@ -128,7 +210,7 @@ describe("statusCommand", () => { it("prints JSON when requested", async () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse((runtime.log as vi.Mock).mock.calls[0][0]); - expect(payload.web.linked).toBe(true); + expect(payload.linkProvider.linked).toBe(true); expect(payload.sessions.count).toBe(1); expect(payload.sessions.path).toBe("/tmp/sessions.json"); expect(payload.sessions.defaults.model).toBeTruthy(); @@ -147,7 +229,7 @@ describe("statusCommand", () => { expect(logs.some((l) => l.includes("Dashboard"))).toBe(true); expect(logs.some((l) => l.includes("macos 14.0 (arm64)"))).toBe(true); expect(logs.some((l) => l.includes("Providers"))).toBe(true); - expect(logs.some((l) => l.includes("Telegram"))).toBe(true); + expect(logs.some((l) => l.includes("WhatsApp"))).toBe(true); expect(logs.some((l) => l.includes("Sessions"))).toBe(true); expect(logs.some((l) => l.includes("+1000"))).toBe(true); expect(logs.some((l) => l.includes("50%"))).toBe(true); @@ -196,24 +278,26 @@ describe("statusCommand", () => { configSnapshot: null, }); mocks.callGateway.mockResolvedValueOnce({ - signalAccounts: [ - { - accountId: "default", - enabled: true, - configured: true, - running: false, - lastError: "signal-cli unreachable", - }, - ], - imessageAccounts: [ - { - accountId: "default", - enabled: true, - configured: true, - running: false, - lastError: "imessage permission denied", - }, - ], + providerAccounts: { + signal: [ + { + accountId: "default", + enabled: true, + configured: true, + running: false, + lastError: "signal-cli unreachable", + }, + ], + imessage: [ + { + accountId: "default", + enabled: true, + configured: true, + running: false, + lastError: "imessage permission denied", + }, + ], + }, }); (runtime.log as vi.Mock).mockClear(); diff --git a/src/commands/status.ts b/src/commands/status.ts index 1c37bbe63..865724ee3 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -10,7 +10,11 @@ import { } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { withProgress } from "../cli/progress.js"; -import { loadConfig, resolveGatewayPort } from "../config/config.js"; +import { + type ClawdbotConfig, + loadConfig, + resolveGatewayPort, +} from "../config/config.js"; import { loadSessionStore, resolveMainSessionKey, @@ -39,14 +43,19 @@ import { type UpdateCheckResult, } from "../infra/update-check.js"; import { runExec } from "../process/exec.js"; +import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js"; +import { listProviderPlugins } from "../providers/plugins/index.js"; +import type { + ProviderAccountSnapshot, + ProviderId, + ProviderPlugin, +} from "../providers/plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { VERSION } from "../version.js"; -import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; -import { getWebAuthAgeMs, webAuthExists } from "../web/session.js"; -import type { HealthSummary } from "./health.js"; +import { formatHealthProviderLines, type HealthSummary } from "./health.js"; import { resolveControlUiLinks } from "./onboard-helpers.js"; import { formatGatewayAuthUsed } from "./status-all/format.js"; import { buildProvidersTable } from "./status-all/providers.js"; @@ -75,7 +84,12 @@ export type SessionStatus = { }; export type StatusSummary = { - web: { linked: boolean; authAgeMs: number | null }; + linkProvider?: { + id: ProviderId; + label: string; + linked: boolean; + authAgeMs: number | null; + }; heartbeatSeconds: number; providerSummary: string[]; queuedSystemEvents: string[]; @@ -87,11 +101,64 @@ export type StatusSummary = { }; }; +type LinkProviderContext = { + linked: boolean; + authAgeMs: number | null; + account?: unknown; + accountId?: string; + plugin: ProviderPlugin; +}; + +async function resolveLinkProviderContext( + cfg: ClawdbotConfig, +): Promise { + for (const plugin of listProviderPlugins()) { + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveProviderDefaultAccountId({ + plugin, + cfg, + accountIds, + }); + const account = plugin.config.resolveAccount(cfg, defaultAccountId); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : true; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + const snapshot = plugin.config.describeAccount + ? plugin.config.describeAccount(account, cfg) + : ({ + accountId: defaultAccountId, + enabled, + configured, + } as ProviderAccountSnapshot); + const summary = plugin.status?.buildProviderSummary + ? await plugin.status.buildProviderSummary({ + account, + cfg, + defaultAccountId, + snapshot, + }) + : undefined; + const summaryRecord = summary as Record | undefined; + const linked = + summaryRecord && typeof summaryRecord.linked === "boolean" + ? summaryRecord.linked + : null; + if (linked === null) continue; + const authAgeMs = + summaryRecord && typeof summaryRecord.authAgeMs === "number" + ? summaryRecord.authAgeMs + : null; + return { linked, authAgeMs, account, accountId: defaultAccountId, plugin }; + } + return null; +} + export async function getStatusSummary(): Promise { const cfg = loadConfig(); - const account = resolveWhatsAppAccount({ cfg }); - const linked = await webAuthExists(account.authDir); - const authAgeMs = getWebAuthAgeMs(account.authDir); + const linkContext = await resolveLinkProviderContext(cfg); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); const providerSummary = await buildProviderSummary(cfg, { colorize: true, @@ -161,7 +228,14 @@ export async function getStatusSummary(): Promise { const recent = sessions.slice(0, 5); return { - web: { linked, authAgeMs }, + linkProvider: linkContext + ? { + id: linkContext.plugin.id, + label: linkContext.plugin.meta.label ?? "Provider", + linked: linkContext.linked, + authAgeMs: linkContext.authAgeMs, + } + : undefined, heartbeatSeconds, providerSummary, queuedSystemEvents, @@ -865,24 +939,6 @@ export async function statusCommand( } return map; })(); - const providerKeyForLabel = (label: string) => { - switch (label) { - case "WhatsApp": - return "whatsapp"; - case "Telegram": - return "telegram"; - case "Discord": - return "discord"; - case "Slack": - return "slack"; - case "Signal": - return "signal"; - case "iMessage": - return "imessage"; - default: - return label.toLowerCase(); - } - }; runtime.log( renderTable({ width: tableWidth, @@ -893,8 +949,7 @@ export async function statusCommand( { key: "Detail", header: "Detail", flex: true, minWidth: 24 }, ], rows: providers.rows.map((row) => { - const providerKey = providerKeyForLabel(row.provider); - const issues = providerIssuesByProvider.get(providerKey) ?? []; + const issues = providerIssuesByProvider.get(row.id) ?? []; const effectiveState = row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state; const issueSuffix = @@ -977,32 +1032,24 @@ export async function statusCommand( Status: ok("reachable"), Detail: `${health.durationMs}ms`, }); - rows.push({ - Provider: "Telegram", - Status: health.telegram.configured - ? health.telegram.probe?.ok - ? ok("OK") - : warn("WARN") - : muted("OFF"), - Detail: health.telegram.configured - ? health.telegram.probe?.ok - ? `@${health.telegram.probe.bot?.username ?? "unknown"} · ${health.telegram.probe.elapsedMs}ms` - : (health.telegram.probe?.error ?? "probe failed") - : "not configured", - }); - rows.push({ - Provider: "Discord", - Status: health.discord.configured - ? health.discord.probe?.ok - ? ok("OK") - : warn("WARN") - : muted("OFF"), - Detail: health.discord.configured - ? health.discord.probe?.ok - ? `@${health.discord.probe.bot?.username ?? "unknown"} · ${health.discord.probe.elapsedMs}ms` - : (health.discord.probe?.error ?? "probe failed") - : "not configured", - }); + + for (const line of formatHealthProviderLines(health)) { + const colon = line.indexOf(":"); + if (colon === -1) continue; + const provider = line.slice(0, colon).trim(); + const detail = line.slice(colon + 1).trim(); + const normalized = detail.toLowerCase(); + const status = (() => { + if (normalized.startsWith("ok")) return ok("OK"); + if (normalized.startsWith("failed")) return warn("WARN"); + if (normalized.startsWith("not configured")) return muted("OFF"); + if (normalized.startsWith("configured")) return ok("OK"); + if (normalized.startsWith("linked")) return ok("LINKED"); + if (normalized.startsWith("not linked")) return warn("UNLINKED"); + return warn("WARN"); + })(); + rows.push({ Provider: provider, Status: status, Detail: detail }); + } runtime.log( renderTable({ diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index 5c9a4f290..63d47026b 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -21,21 +21,22 @@ function resolveProviderGroups( provider: GroupPolicyProvider, accountId?: string | null, ): ProviderGroups | undefined { - if (provider === "whatsapp") return cfg.whatsapp?.groups; const normalizedAccountId = normalizeAccountId(accountId); - if (provider === "telegram") { - return ( - cfg.telegram?.accounts?.[normalizedAccountId]?.groups ?? - cfg.telegram?.groups - ); - } - if (provider === "imessage") { - return ( - cfg.imessage?.accounts?.[normalizedAccountId]?.groups ?? - cfg.imessage?.groups - ); - } - return undefined; + const providerConfig = (cfg as Record)[provider] as + | { + accounts?: Record; + groups?: ProviderGroups; + } + | undefined; + if (!providerConfig) return undefined; + const accountGroups = + providerConfig.accounts?.[normalizedAccountId]?.groups ?? + providerConfig.accounts?.[ + Object.keys(providerConfig.accounts ?? {}).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ) ?? "" + ]?.groups; + return accountGroups ?? providerConfig.groups; } export function resolveProviderGroupPolicy(params: { diff --git a/src/config/provider-capabilities.ts b/src/config/provider-capabilities.ts index 74695e434..867b99371 100644 --- a/src/config/provider-capabilities.ts +++ b/src/config/provider-capabilities.ts @@ -1,3 +1,4 @@ +import { normalizeProviderId } from "../providers/registry.js"; import { normalizeAccountId } from "../routing/session-key.js"; import type { ClawdbotConfig } from "./config.js"; @@ -49,43 +50,17 @@ export function resolveProviderCapabilities(params: { accountId?: string | null; }): string[] | undefined { const cfg = params.cfg; - const provider = params.provider?.trim().toLowerCase(); + const provider = normalizeProviderId(params.provider); if (!cfg || !provider) return undefined; - switch (provider) { - case "whatsapp": - return resolveAccountCapabilities({ - cfg: cfg.whatsapp, - accountId: params.accountId, - }); - case "telegram": - return resolveAccountCapabilities({ - cfg: cfg.telegram, - accountId: params.accountId, - }); - case "discord": - return resolveAccountCapabilities({ - cfg: cfg.discord, - accountId: params.accountId, - }); - case "slack": - return resolveAccountCapabilities({ - cfg: cfg.slack, - accountId: params.accountId, - }); - case "signal": - return resolveAccountCapabilities({ - cfg: cfg.signal, - accountId: params.accountId, - }); - case "imessage": - return resolveAccountCapabilities({ - cfg: cfg.imessage, - accountId: params.accountId, - }); - case "msteams": - return normalizeCapabilities(cfg.msteams?.capabilities); - default: - return undefined; - } + const providerConfig = (cfg as Record)[provider] as + | { + accounts?: Record; + capabilities?: string[]; + } + | undefined; + return resolveAccountCapabilities({ + cfg: providerConfig, + accountId: params.accountId, + }); } diff --git a/src/config/sessions.ts b/src/config/sessions.ts index c8ea348c8..53d7dcc62 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -6,6 +6,8 @@ import path from "node:path"; import type { Skill } from "@mariozechner/pi-coding-agent"; import JSON5 from "json5"; import type { MsgContext } from "../auto-reply/templating.js"; +import type { ProviderId } from "../providers/plugins/types.js"; +import { PROVIDER_IDS } from "../providers/registry.js"; import { buildAgentMainSessionKey, DEFAULT_AGENT_ID, @@ -63,15 +65,9 @@ export function clearSessionStoreCacheForTest(): void { export type SessionScope = "per-sender" | "global"; -const GROUP_SURFACES = new Set([ - "whatsapp", - "telegram", - "discord", - "signal", - "imessage", - "webchat", - "slack", -]); +export type SessionProviderId = ProviderId | "webchat"; + +const GROUP_SURFACES = new Set([...PROVIDER_IDS, "webchat"]); export type SessionChatType = "direct" | "group" | "room"; @@ -121,14 +117,7 @@ export type SessionEntry = { subject?: string; room?: string; space?: string; - lastProvider?: - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "webchat"; + lastProvider?: SessionProviderId; lastTo?: string; lastAccountId?: string; skillsSnapshot?: SessionSkillSnapshot; diff --git a/src/config/types.ts b/src/config/types.ts index 235177962..16fbe4dc8 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -101,16 +101,10 @@ export type WebConfig = { reconnect?: WebReconnectConfig; }; -export type AgentElevatedAllowFromConfig = { - whatsapp?: string[]; - telegram?: Array; - discord?: Array; - slack?: Array; - signal?: Array; - imessage?: Array; - msteams?: Array; - webchat?: Array; -}; +// Provider docking: allowlists keyed by provider id (and internal "webchat"). +export type AgentElevatedAllowFromConfig = Partial< + Record> +>; export type IdentityConfig = { name?: string; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d7fd6d04b..dd8ff021f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -804,16 +804,9 @@ const ToolPolicySchema = z }) .optional(); +// Provider docking: allowlists keyed by provider id (no schema updates when adding providers). const ElevatedAllowFromSchema = z - .object({ - whatsapp: z.array(z.string()).optional(), - telegram: z.array(z.union([z.string(), z.number()])).optional(), - discord: z.array(z.union([z.string(), z.number()])).optional(), - slack: z.array(z.union([z.string(), z.number()])).optional(), - signal: z.array(z.union([z.string(), z.number()])).optional(), - imessage: z.array(z.union([z.string(), z.number()])).optional(), - webchat: z.array(z.union([z.string(), z.number()])).optional(), - }) + .record(z.string(), z.array(z.union([z.string(), z.number()]))) .optional(); const AgentSandboxSchema = z diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 4edf10c6a..967c14c4d 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -445,7 +445,7 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("passes telegram token from config for delivery", async () => { + it("delivers telegram via provider send", async () => { await withTempHome(async (home) => { const storePath = await writeSessionStore(home); const deps: CliDeps = { @@ -488,7 +488,7 @@ describe("runCronIsolatedAgentTurn", () => { expect(deps.sendMessageTelegram).toHaveBeenCalledWith( "123", "hello from cron", - expect.objectContaining({ token: "t-1" }), + expect.objectContaining({ verbose: false }), ); } finally { if (prevTelegramToken === undefined) { @@ -500,7 +500,7 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("delivers telegram topic targets with messageThreadId", async () => { + it("delivers telegram topic targets via provider send", async () => { await withTempHome(async (home) => { const storePath = await writeSessionStore(home); const deps: CliDeps = { @@ -538,14 +538,14 @@ describe("runCronIsolatedAgentTurn", () => { expect(res.status).toBe("ok"); expect(deps.sendMessageTelegram).toHaveBeenCalledWith( - "-1001234567890", + "telegram:group:-1001234567890:topic:321", "hello from cron", - expect.objectContaining({ messageThreadId: 321 }), + expect.objectContaining({ verbose: false }), ); }); }); - it("delivers telegram shorthand topic suffixes with messageThreadId", async () => { + it("delivers telegram shorthand topic suffixes via provider send", async () => { await withTempHome(async (home) => { const storePath = await writeSessionStore(home); const deps: CliDeps = { @@ -583,9 +583,9 @@ describe("runCronIsolatedAgentTurn", () => { expect(res.status).toBe("ok"); expect(deps.sendMessageTelegram).toHaveBeenCalledWith( - "-1001234567890", + "-1001234567890:321", "hello from cron", - expect.objectContaining({ messageThreadId: 321 }), + expect.objectContaining({ verbose: false }), ); }); }); @@ -630,7 +630,7 @@ describe("runCronIsolatedAgentTurn", () => { expect(deps.sendMessageDiscord).toHaveBeenCalledWith( "channel:1122", "hello from cron", - expect.objectContaining({ token: process.env.DISCORD_BOT_TOKEN }), + expect.objectContaining({ verbose: false }), ); }); }); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 017e33092..cdee2cfc8 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -25,11 +25,6 @@ import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; -import { - chunkMarkdownText, - chunkText, - resolveTextChunkLimit, -} from "../auto-reply/chunk.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken, @@ -48,13 +43,20 @@ import { saveSessionStore, } from "../config/sessions.js"; import { registerAgentRunContext } from "../infra/agent-events.js"; -import { parseTelegramTarget } from "../telegram/targets.js"; -import { resolveTelegramToken } from "../telegram/token.js"; -import { normalizeE164, truncateUtf16Safe } from "../utils.js"; +import { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; +import { resolveMessageProviderSelection } from "../infra/outbound/provider-selection.js"; import { - isWhatsAppGroupJid, - normalizeWhatsAppTarget, -} from "../whatsapp/normalize.js"; + type OutboundProvider, + resolveOutboundTarget, +} from "../infra/outbound/targets.js"; +import { normalizeProviderId } from "../providers/plugins/index.js"; +import type { ProviderId } from "../providers/plugins/types.js"; +import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js"; +import { + INTERNAL_MESSAGE_PROVIDER, + normalizeMessageProvider, +} from "../utils/message-provider.js"; +import { truncateUtf16Safe } from "../utils.js"; import type { CronJob } from "./types.js"; export type RunCronAgentTurnResult = { @@ -109,69 +111,23 @@ function isHeartbeatOnlyResponse( }); } -function getMediaList(payload: DeliveryPayload) { - return payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); -} - -async function deliverPayloadsWithMedia(params: { - payloads: DeliveryPayload[]; - sendText: (text: string) => Promise; - sendMedia: (caption: string, mediaUrl: string) => Promise; -}) { - for (const payload of params.payloads) { - const mediaList = getMediaList(payload); - if (mediaList.length === 0) { - await params.sendText(payload.text ?? ""); - continue; - } - let first = true; - for (const url of mediaList) { - const caption = first ? (payload.text ?? "") : ""; - first = false; - await params.sendMedia(caption, url); - } - } -} - -async function deliverChunkedPayloads(params: { - payloads: DeliveryPayload[]; - chunkText: (text: string) => string[]; - sendText: (text: string) => Promise; - sendMedia: (caption: string, mediaUrl: string) => Promise; -}) { - for (const payload of params.payloads) { - const mediaList = getMediaList(payload); - if (mediaList.length === 0) { - for (const chunk of params.chunkText(payload.text ?? "")) { - await params.sendText(chunk); - } - continue; - } - let first = true; - for (const url of mediaList) { - const caption = first ? (payload.text ?? "") : ""; - first = false; - await params.sendMedia(caption, url); - } - } -} -function resolveDeliveryTarget( +async function resolveDeliveryTarget( cfg: ClawdbotConfig, jobPayload: { - provider?: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams"; + provider?: "last" | ProviderId; to?: string; }, -) { - const requestedProvider = +): Promise<{ + provider: string; + to?: string; + accountId?: string; + mode: "explicit" | "implicit"; + error?: Error; +}> { + const requestedRaw = typeof jobPayload.provider === "string" ? jobPayload.provider : "last"; + const requestedProvider = + normalizeMessageProvider(requestedRaw) ?? requestedRaw; const explicitTo = typeof jobPayload.to === "string" && jobPayload.to.trim() ? jobPayload.to.trim() @@ -184,60 +140,46 @@ function resolveDeliveryTarget( const store = loadSessionStore(storePath); const main = store[mainSessionKey]; const lastProvider = - main?.lastProvider && main.lastProvider !== "webchat" - ? main.lastProvider + main?.lastProvider && main.lastProvider !== INTERNAL_MESSAGE_PROVIDER + ? (normalizeProviderId(main.lastProvider) ?? main.lastProvider) : undefined; const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : ""; + const lastAccountId = main?.lastAccountId; - const provider = (() => { - if ( - requestedProvider === "whatsapp" || - requestedProvider === "telegram" || - requestedProvider === "discord" || - requestedProvider === "slack" || - requestedProvider === "signal" || - requestedProvider === "imessage" - ) { - return requestedProvider; + let provider = + requestedProvider === "last" + ? lastProvider + : requestedProvider === INTERNAL_MESSAGE_PROVIDER + ? undefined + : normalizeProviderId(requestedProvider); + if (!provider) { + try { + const selection = await resolveMessageProviderSelection({ cfg }); + provider = selection.provider; + } catch { + provider = lastProvider ?? DEFAULT_CHAT_PROVIDER; } - return lastProvider ?? "whatsapp"; - })(); + } - const rawTo = explicitTo ?? (lastTo || undefined); - const telegramTarget = - provider === "telegram" && rawTo ? parseTelegramTarget(rawTo) : undefined; - - const sanitizedWhatsappTo = (() => { - if (provider !== "whatsapp") return rawTo; - if (rawTo && isWhatsAppGroupJid(rawTo)) { - return normalizeWhatsAppTarget(rawTo) ?? rawTo; - } - const rawAllow = cfg.whatsapp?.allowFrom ?? []; - if (rawAllow.includes("*")) { - return rawTo ? (normalizeWhatsAppTarget(rawTo) ?? rawTo) : rawTo; - } - const allowFrom = rawAllow - .map((val) => normalizeE164(val)) - .filter((val) => val.length > 1); - if (allowFrom.length === 0) { - return rawTo ? (normalizeWhatsAppTarget(rawTo) ?? rawTo) : rawTo; - } - if (!rawTo) return allowFrom[0]; - const normalized = normalizeWhatsAppTarget(rawTo); - if (normalized && allowFrom.includes(normalized)) return normalized; - return allowFrom[0]; - })(); - - const to = (() => { - if (provider === "telegram" && telegramTarget) return telegramTarget.chatId; - if (provider === "whatsapp") return sanitizedWhatsappTo; - return rawTo; - })(); + const toCandidate = explicitTo ?? (lastTo || undefined); + const mode: "explicit" | "implicit" = explicitTo ? "explicit" : "implicit"; + if (!toCandidate) { + return { provider, to: undefined, accountId: lastAccountId, mode }; + } + const resolved = resolveOutboundTarget({ + provider: provider as Exclude, + to: toCandidate, + cfg, + accountId: provider === lastProvider ? lastAccountId : undefined, + mode, + }); return { provider, - to, - messageThreadId: telegramTarget?.messageThreadId, + to: resolved.ok ? resolved.to : undefined, + accountId: provider === lastProvider ? lastAccountId : undefined, + mode, + error: resolved.ok ? undefined : resolved.error, }; } @@ -389,7 +331,7 @@ export async function runCronIsolatedAgentTurn(params: { params.job.payload.kind === "agentTurn" && params.job.payload.bestEffortDeliver === true; - const resolvedDelivery = resolveDeliveryTarget(params.cfg, { + const resolvedDelivery = await resolveDeliveryTarget(params.cfg, { provider: params.job.payload.kind === "agentTurn" ? params.job.payload.provider @@ -399,7 +341,6 @@ export async function runCronIsolatedAgentTurn(params: { ? params.job.payload.to : undefined, }); - const { token: telegramToken } = resolveTelegramToken(params.cfg); const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim(); @@ -546,194 +487,56 @@ export async function runCronIsolatedAgentTurn(params: { delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars)); if (delivery && !skipHeartbeatDelivery) { - if (resolvedDelivery.provider === "whatsapp") { - if (!resolvedDelivery.to) { - if (!bestEffortDeliver) - return { - status: "error", - summary, - error: "Cron delivery to WhatsApp requires a recipient.", - }; + if (!resolvedDelivery.to) { + const reason = + resolvedDelivery.error?.message ?? + "Cron delivery requires a recipient (--to)."; + if (!bestEffortDeliver) { return { - status: "skipped", - summary: "Delivery skipped (no WhatsApp recipient).", + status: "error", + summary, + error: reason, }; } - const rawTo = resolvedDelivery.to; - const to = normalizeWhatsAppTarget(rawTo) ?? rawTo; - try { - await deliverPayloadsWithMedia({ - payloads, - sendText: (text) => - params.deps.sendMessageWhatsApp(to, text, { verbose: false }), - sendMedia: (caption, mediaUrl) => - params.deps.sendMessageWhatsApp(to, caption, { - verbose: false, - mediaUrl, - }), - }); - } catch (err) { - if (!bestEffortDeliver) - return { status: "error", summary, error: String(err) }; - return { status: "ok", summary }; - } - } else if (resolvedDelivery.provider === "telegram") { - if (!resolvedDelivery.to) { - if (!bestEffortDeliver) - return { - status: "error", - summary, - error: "Cron delivery to Telegram requires a chatId.", - }; - return { - status: "skipped", - summary: "Delivery skipped (no Telegram chatId).", - }; - } - const chatId = resolvedDelivery.to; - const messageThreadId = resolvedDelivery.messageThreadId; - const textLimit = resolveTextChunkLimit(params.cfg, "telegram"); - try { - await deliverChunkedPayloads({ - payloads, - chunkText: (text) => chunkMarkdownText(text, textLimit), - sendText: (text) => - params.deps.sendMessageTelegram(chatId, text, { - verbose: false, - token: telegramToken || undefined, - messageThreadId, - }), - sendMedia: (caption, mediaUrl) => - params.deps.sendMessageTelegram(chatId, caption, { - verbose: false, - mediaUrl, - token: telegramToken || undefined, - messageThreadId, - }), - }); - } catch (err) { - if (!bestEffortDeliver) - return { status: "error", summary, error: String(err) }; - return { status: "ok", summary }; - } - } else if (resolvedDelivery.provider === "discord") { - if (!resolvedDelivery.to) { - if (!bestEffortDeliver) - return { - status: "error", - summary, - error: - "Cron delivery to Discord requires --provider discord and --to ", - }; - return { - status: "skipped", - summary: "Delivery skipped (no Discord destination).", - }; - } - const discordTarget = resolvedDelivery.to; - try { - await deliverPayloadsWithMedia({ - payloads, - sendText: (text) => - params.deps.sendMessageDiscord(discordTarget, text, { - token: process.env.DISCORD_BOT_TOKEN, - }), - sendMedia: (caption, mediaUrl) => - params.deps.sendMessageDiscord(discordTarget, caption, { - token: process.env.DISCORD_BOT_TOKEN, - mediaUrl, - }), - }); - } catch (err) { - if (!bestEffortDeliver) - return { status: "error", summary, error: String(err) }; - return { status: "ok", summary }; - } - } else if (resolvedDelivery.provider === "slack") { - if (!resolvedDelivery.to) { - if (!bestEffortDeliver) - return { - status: "error", - summary, - error: - "Cron delivery to Slack requires --provider slack and --to ", - }; - return { - status: "skipped", - summary: "Delivery skipped (no Slack destination).", - }; - } - const slackTarget = resolvedDelivery.to; - const textLimit = resolveTextChunkLimit(params.cfg, "slack"); - try { - await deliverChunkedPayloads({ - payloads, - chunkText: (text) => chunkMarkdownText(text, textLimit), - sendText: (text) => params.deps.sendMessageSlack(slackTarget, text), - sendMedia: (caption, mediaUrl) => - params.deps.sendMessageSlack(slackTarget, caption, { mediaUrl }), - }); - } catch (err) { - if (!bestEffortDeliver) - return { status: "error", summary, error: String(err) }; - return { status: "ok", summary }; - } - } else if (resolvedDelivery.provider === "signal") { - if (!resolvedDelivery.to) { - if (!bestEffortDeliver) - return { - status: "error", - summary, - error: "Cron delivery to Signal requires a recipient.", - }; - return { - status: "skipped", - summary: "Delivery skipped (no Signal recipient).", - }; - } - const to = resolvedDelivery.to; - const textLimit = resolveTextChunkLimit(params.cfg, "signal"); - try { - await deliverChunkedPayloads({ - payloads, - chunkText: (text) => chunkText(text, textLimit), - sendText: (text) => params.deps.sendMessageSignal(to, text), - sendMedia: (caption, mediaUrl) => - params.deps.sendMessageSignal(to, caption, { mediaUrl }), - }); - } catch (err) { - if (!bestEffortDeliver) - return { status: "error", summary, error: String(err) }; - return { status: "ok", summary }; - } - } else if (resolvedDelivery.provider === "imessage") { - if (!resolvedDelivery.to) { - if (!bestEffortDeliver) - return { - status: "error", - summary, - error: "Cron delivery to iMessage requires a recipient.", - }; - return { - status: "skipped", - summary: "Delivery skipped (no iMessage recipient).", - }; - } - const to = resolvedDelivery.to; - const textLimit = resolveTextChunkLimit(params.cfg, "imessage"); - try { - await deliverChunkedPayloads({ - payloads, - chunkText: (text) => chunkText(text, textLimit), - sendText: (text) => params.deps.sendMessageIMessage(to, text), - sendMedia: (caption, mediaUrl) => - params.deps.sendMessageIMessage(to, caption, { mediaUrl }), - }); - } catch (err) { - if (!bestEffortDeliver) - return { status: "error", summary, error: String(err) }; - return { status: "ok", summary }; + return { + status: "skipped", + summary: `Delivery skipped (${reason}).`, + }; + } + try { + await deliverOutboundPayloads({ + cfg: params.cfg, + provider: resolvedDelivery.provider as Exclude< + OutboundProvider, + "none" + >, + to: resolvedDelivery.to, + accountId: resolvedDelivery.accountId, + payloads, + bestEffort: bestEffortDeliver, + deps: { + sendWhatsApp: params.deps.sendMessageWhatsApp, + sendTelegram: params.deps.sendMessageTelegram, + sendDiscord: params.deps.sendMessageDiscord, + sendSlack: params.deps.sendMessageSlack, + sendSignal: params.deps.sendMessageSignal, + sendIMessage: params.deps.sendMessageIMessage, + sendMSTeams: params.deps.sendMessageMSTeams + ? async (to, text, opts) => + await params.deps.sendMessageMSTeams({ + cfg: params.cfg, + to, + text, + mediaUrl: opts?.mediaUrl, + }) + : undefined, + }, + }); + } catch (err) { + if (!bestEffortDeliver) { + return { status: "error", summary, error: String(err) }; } + return { status: "ok", summary }; } } diff --git a/src/cron/types.ts b/src/cron/types.ts index 1112a4100..92b70dd4f 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -1,3 +1,5 @@ +import type { ProviderId } from "../providers/plugins/types.js"; + export type CronSchedule = | { kind: "at"; atMs: number } | { kind: "every"; everyMs: number; anchorMs?: number } @@ -6,6 +8,8 @@ export type CronSchedule = export type CronSessionTarget = "main" | "isolated"; export type CronWakeMode = "next-heartbeat" | "now"; +export type CronMessageProvider = ProviderId | "last"; + export type CronPayload = | { kind: "systemEvent"; text: string } | { @@ -16,15 +20,7 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - provider?: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams"; + provider?: CronMessageProvider; to?: string; bestEffortDeliver?: boolean; }; diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index fe6be3532..458112675 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -386,7 +386,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const allowFrom = dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024; - const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId); + const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, { + fallbackLimit: 2000, + }); const historyLimit = Math.max( 0, opts.historyLimit ?? @@ -1701,7 +1703,9 @@ function createDiscordNativeCommand(params: { await deliverDiscordInteractionReply({ interaction, payload, - textLimit: resolveTextChunkLimit(cfg, "discord", accountId), + textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { + fallbackLimit: 2000, + }), maxLinesPerMessage: discordConfig?.maxLinesPerMessage, preferFollowUp: didReply, }); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 800fc4cb5..dc8fa2314 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -7,6 +7,12 @@ import { resolveStateDir, } from "../config/config.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + type GatewayClientMode, + type GatewayClientName, +} from "../utils/message-provider.js"; import { GatewayClient } from "./client.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; @@ -18,10 +24,11 @@ export type CallGatewayOptions = { params?: unknown; expectFinal?: boolean; timeoutMs?: number; - clientName?: string; + clientName?: GatewayClientName; + clientDisplayName?: string; clientVersion?: string; platform?: string; - mode?: string; + mode?: GatewayClientMode; instanceId?: string; minProtocol?: number; maxProtocol?: number; @@ -191,10 +198,11 @@ export async function callGateway( token, password, instanceId: opts.instanceId ?? randomUUID(), - clientName: opts.clientName ?? "cli", + clientName: opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI, + clientDisplayName: opts.clientDisplayName, clientVersion: opts.clientVersion ?? "dev", platform: opts.platform, - mode: opts.mode ?? "cli", + mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async () => { diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 33b82dd38..6edee3579 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -2,6 +2,12 @@ import { randomUUID } from "node:crypto"; import { WebSocket } from "ws"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + type GatewayClientMode, + type GatewayClientName, +} from "../utils/message-provider.js"; import { type ConnectParams, type EventFrame, @@ -24,10 +30,11 @@ export type GatewayClientOptions = { token?: string; password?: string; instanceId?: string; - clientName?: string; + clientName?: GatewayClientName; + clientDisplayName?: string; clientVersion?: string; platform?: string; - mode?: string; + mode?: GatewayClientMode; minProtocol?: number; maxProtocol?: number; onEvent?: (evt: EventFrame) => void; @@ -109,10 +116,11 @@ export class GatewayClient { minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION, client: { - name: this.opts.clientName ?? "gateway-client", + id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + displayName: this.opts.clientDisplayName, version: this.opts.clientVersion ?? "dev", platform: this.opts.platform ?? process.platform, - mode: this.opts.mode ?? "backend", + mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND, instanceId: this.opts.instanceId, }, caps: [], @@ -135,7 +143,7 @@ export class GatewayClient { err instanceof Error ? err : new Error(String(err)), ); const msg = `gateway connect failed: ${String(err)}`; - if (this.opts.mode === "probe") logDebug(msg); + if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) logDebug(msg); else logError(msg); this.ws?.close(1008, "connect failed"); }); diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index cd3c0aba0..a04e05e74 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { listProviderPlugins } from "../providers/plugins/index.js"; import { buildGatewayReloadPlan, diffConfigPaths, @@ -35,11 +36,23 @@ describe("buildGatewayReloadPlan", () => { expect(plan.reloadHooks).toBe(true); }); - it("restarts providers for web/telegram changes", () => { - const plan = buildGatewayReloadPlan(["web.enabled", "telegram.botToken"]); + it("restarts providers when provider config prefixes change", () => { + const changedPaths = ["web.enabled", "telegram.botToken"]; + const plan = buildGatewayReloadPlan(changedPaths); expect(plan.restartGateway).toBe(false); - expect(plan.restartProviders.has("whatsapp")).toBe(true); - expect(plan.restartProviders.has("telegram")).toBe(true); + const expected = new Set( + listProviderPlugins() + .filter((plugin) => + (plugin.reload?.configPrefixes ?? []).some((prefix) => + changedPaths.some( + (path) => path === prefix || path.startsWith(`${prefix}.`), + ), + ), + ) + .map((plugin) => plugin.id), + ); + expect(expected.size).toBeGreaterThan(0); + expect(plan.restartProviders).toEqual(expected); }); it("treats gateway.remote as no-op", () => { diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index f33f958f4..f6475d9bd 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -5,20 +5,17 @@ import type { ConfigFileSnapshot, GatewayReloadMode, } from "../config/config.js"; +import { + listProviderPlugins, + type ProviderId, +} from "../providers/plugins/index.js"; export type GatewayReloadSettings = { mode: GatewayReloadMode; debounceMs: number; }; -export type ProviderKind = - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams"; +export type ProviderKind = ProviderId; export type GatewayReloadPlan = { changedPaths: string[]; @@ -46,20 +43,14 @@ type ReloadAction = | "restart-browser-control" | "restart-cron" | "restart-heartbeat" - | "restart-provider:whatsapp" - | "restart-provider:telegram" - | "restart-provider:discord" - | "restart-provider:slack" - | "restart-provider:signal" - | "restart-provider:imessage" - | "restart-provider:msteams"; + | `restart-provider:${ProviderId}`; const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = { mode: "hybrid", debounceMs: 300, }; -const RELOAD_RULES: ReloadRule[] = [ +const BASE_RELOAD_RULES: ReloadRule[] = [ { prefix: "gateway.remote", kind: "none" }, { prefix: "gateway.reload", kind: "none" }, { prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] }, @@ -69,29 +60,28 @@ const RELOAD_RULES: ReloadRule[] = [ kind: "hot", actions: ["restart-heartbeat"], }, + { prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, { prefix: "cron", kind: "hot", actions: ["restart-cron"] }, { prefix: "browser", kind: "hot", actions: ["restart-browser-control"], }, - { prefix: "web", kind: "hot", actions: ["restart-provider:whatsapp"] }, - { prefix: "telegram", kind: "hot", actions: ["restart-provider:telegram"] }, - { prefix: "discord", kind: "hot", actions: ["restart-provider:discord"] }, - { prefix: "slack", kind: "hot", actions: ["restart-provider:slack"] }, - { prefix: "signal", kind: "hot", actions: ["restart-provider:signal"] }, - { prefix: "imessage", kind: "hot", actions: ["restart-provider:imessage"] }, - { prefix: "msteams", kind: "hot", actions: ["restart-provider:msteams"] }, +]; + +const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [ + { prefix: "identity", kind: "none" }, + { prefix: "wizard", kind: "none" }, + { prefix: "logging", kind: "none" }, + { prefix: "models", kind: "none" }, { prefix: "agents", kind: "none" }, { prefix: "tools", kind: "none" }, { prefix: "bindings", kind: "none" }, { prefix: "audio", kind: "none" }, - { prefix: "wizard", kind: "none" }, - { prefix: "logging", kind: "none" }, - { prefix: "models", kind: "none" }, + { prefix: "agent", kind: "none" }, + { prefix: "routing", kind: "none" }, { prefix: "messages", kind: "none" }, { prefix: "session", kind: "none" }, - { prefix: "whatsapp", kind: "none" }, { prefix: "talk", kind: "none" }, { prefix: "skills", kind: "none" }, { prefix: "ui", kind: "none" }, @@ -101,8 +91,39 @@ const RELOAD_RULES: ReloadRule[] = [ { prefix: "canvasHost", kind: "restart" }, ]; +let cachedReloadRules: ReloadRule[] | null = null; + +function listReloadRules(): ReloadRule[] { + if (cachedReloadRules) return cachedReloadRules; + // Provider docking: plugins contribute hot reload/no-op prefixes here. + const providerReloadRules: ReloadRule[] = listProviderPlugins().flatMap( + (plugin) => [ + ...(plugin.reload?.configPrefixes ?? []).map( + (prefix): ReloadRule => ({ + prefix, + kind: "hot", + actions: [`restart-provider:${plugin.id}` as ReloadAction], + }), + ), + ...(plugin.reload?.noopPrefixes ?? []).map( + (prefix): ReloadRule => ({ + prefix, + kind: "none", + }), + ), + ], + ); + const rules = [ + ...BASE_RELOAD_RULES, + ...providerReloadRules, + ...BASE_RELOAD_RULES_TAIL, + ]; + cachedReloadRules = rules; + return rules; +} + function matchRule(path: string): ReloadRule | null { - for (const rule of RELOAD_RULES) { + for (const rule of listReloadRules()) { if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) return rule; } return null; @@ -186,6 +207,11 @@ export function buildGatewayReloadPlan( }; const applyAction = (action: ReloadAction) => { + if (action.startsWith("restart-provider:")) { + const provider = action.slice("restart-provider:".length) as ProviderId; + plan.restartProviders.add(provider); + return; + } switch (action) { case "reload-hooks": plan.reloadHooks = true; @@ -202,27 +228,6 @@ export function buildGatewayReloadPlan( case "restart-heartbeat": plan.restartHeartbeat = true; break; - case "restart-provider:whatsapp": - plan.restartProviders.add("whatsapp"); - break; - case "restart-provider:telegram": - plan.restartProviders.add("telegram"); - break; - case "restart-provider:discord": - plan.restartProviders.add("discord"); - break; - case "restart-provider:slack": - plan.restartProviders.add("slack"); - break; - case "restart-provider:signal": - plan.restartProviders.add("signal"); - break; - case "restart-provider:imessage": - plan.restartProviders.add("imessage"); - break; - case "restart-provider:msteams": - plan.restartProviders.add("msteams"); - break; default: break; } diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 86b393c22..5dee21e9f 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -14,6 +14,10 @@ import { resolveClawdbotAgentDir } from "../agents/agent-paths.js"; import { getApiKeyForModel } from "../agents/model-auth.js"; import { ensureClawdbotModelsJson } from "../agents/models-config.js"; import { loadConfig } from "../config/config.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { resolveUserPath } from "../utils.js"; import { GatewayClient } from "./client.js"; import { renderCatNoncePngBase64 } from "./live-image-probe.js"; @@ -115,7 +119,6 @@ function editDistance(a: string, b: string): number { return prev[bLen] ?? Number.POSITIVE_INFINITY; } - async function getFreePort(): Promise { return await new Promise((resolve, reject) => { const srv = createServer(); @@ -179,9 +182,10 @@ async function connectClient(params: { url: string; token: string }) { const client = new GatewayClient({ url: params.url, token: params.token, - clientName: "vitest-live", + clientName: GATEWAY_CLIENT_NAMES.TEST, + clientDisplayName: "vitest-live", clientVersion: "dev", - mode: "test", + mode: GATEWAY_CLIENT_MODES.TEST, onHelloOk: () => stop(undefined, client), onConnectError: (err) => stop(err), onClose: (code, reason) => @@ -491,7 +495,6 @@ describeLive("gateway live (dev agent, profile keys)", () => { ); } } - // Regression: tool-call-only turn followed by a user message (OpenAI responses bug class). if ( (model.provider === "openai" && diff --git a/src/gateway/gateway.tool-calling.mock-openai.test.ts b/src/gateway/gateway.tool-calling.mock-openai.test.ts index 92b409f8c..cada82563 100644 --- a/src/gateway/gateway.tool-calling.mock-openai.test.ts +++ b/src/gateway/gateway.tool-calling.mock-openai.test.ts @@ -5,6 +5,10 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { GatewayClient } from "./client.js"; import { startGatewayServer } from "./server.js"; @@ -246,9 +250,10 @@ async function connectClient(params: { url: string; token: string }) { const client = new GatewayClient({ url: params.url, token: params.token, - clientName: "vitest-mock-openai", + clientName: GATEWAY_CLIENT_NAMES.TEST, + clientDisplayName: "vitest-mock-openai", clientVersion: "dev", - mode: "test", + mode: GATEWAY_CLIENT_MODES.TEST, onHelloOk: () => stop(undefined, client), onConnectError: (err) => stop(err), onClose: (code, reason) => diff --git a/src/gateway/gateway.wizard.e2e.test.ts b/src/gateway/gateway.wizard.e2e.test.ts index 8ff79a004..9433e2c9a 100644 --- a/src/gateway/gateway.wizard.e2e.test.ts +++ b/src/gateway/gateway.wizard.e2e.test.ts @@ -8,6 +8,10 @@ import { describe, expect, it } from "vitest"; import { WebSocket } from "ws"; import { rawDataToString } from "../infra/ws.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; async function getFreePort(): Promise { @@ -92,10 +96,11 @@ async function connectReq(params: { url: string; token?: string }) { minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { - name: "vitest", + id: GATEWAY_CLIENT_NAMES.TEST, + displayName: "vitest", version: "dev", platform: process.platform, - mode: "test", + mode: GATEWAY_CLIENT_MODES.TEST, }, caps: [], auth: params.token ? { token: params.token } : undefined, @@ -133,9 +138,10 @@ async function connectClient(params: { url: string; token?: string }) { const client = new GatewayClient({ url: params.url, token: params.token, - clientName: "vitest-wizard", + clientName: GATEWAY_CLIENT_NAMES.TEST, + clientDisplayName: "vitest-wizard", clientVersion: "dev", - mode: "test", + mode: GATEWAY_CLIENT_MODES.TEST, onHelloOk: () => stop(undefined, client), onConnectError: (err) => stop(err), onClose: (code, reason) => diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index b9852e7a9..299f8326d 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -1,6 +1,10 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage } from "node:http"; import type { ClawdbotConfig } from "../config/config.js"; +import { + listProviderPlugins, + type ProviderId, +} from "../providers/plugins/index.js"; import { normalizeMessageProvider } from "../utils/message-provider.js"; import { type HookMappingResolved, @@ -147,16 +151,10 @@ export type HookAgentPayload = { const HOOK_PROVIDER_VALUES = [ "last", - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "msteams", -] as const; + ...listProviderPlugins().map((plugin) => plugin.id), +]; -export type HookMessageProvider = (typeof HOOK_PROVIDER_VALUES)[number]; +export type HookMessageProvider = ProviderId | "last"; const hookProviderSet = new Set(HOOK_PROVIDER_VALUES); export const HOOK_PROVIDER_ERROR = `provider must be ${HOOK_PROVIDER_VALUES.join("|")}`; diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 236199935..d43194371 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -1,6 +1,10 @@ import { randomUUID } from "node:crypto"; import type { SystemPresence } from "../infra/system-presence.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { GatewayClient } from "./client.js"; export type GatewayProbeAuth = { @@ -56,9 +60,9 @@ export async function probeGateway(opts: { url: opts.url, token: opts.auth?.token, password: opts.auth?.password, - clientName: "cli", + clientName: GATEWAY_CLIENT_NAMES.CLI, clientVersion: "dev", - mode: "probe", + mode: GATEWAY_CLIENT_MODES.PROBE, instanceId, onConnectError: (err) => { connectError = formatError(err); diff --git a/src/gateway/protocol/client-info.ts b/src/gateway/protocol/client-info.ts new file mode 100644 index 000000000..70ebea185 --- /dev/null +++ b/src/gateway/protocol/client-info.ts @@ -0,0 +1,74 @@ +export const GATEWAY_CLIENT_IDS = { + WEBCHAT_UI: "webchat-ui", + CONTROL_UI: "clawdbot-control-ui", + WEBCHAT: "webchat", + CLI: "cli", + GATEWAY_CLIENT: "gateway-client", + MACOS_APP: "clawdbot-macos", + TEST: "test", + FINGERPRINT: "fingerprint", + PROBE: "clawdbot-probe", +} as const; + +export type GatewayClientId = + (typeof GATEWAY_CLIENT_IDS)[keyof typeof GATEWAY_CLIENT_IDS]; + +// Back-compat naming (internal): these values are IDs, not display names. +export const GATEWAY_CLIENT_NAMES = GATEWAY_CLIENT_IDS; +export type GatewayClientName = GatewayClientId; + +export const GATEWAY_CLIENT_MODES = { + WEBCHAT: "webchat", + CLI: "cli", + UI: "ui", + BACKEND: "backend", + PROBE: "probe", + TEST: "test", +} as const; + +export type GatewayClientMode = + (typeof GATEWAY_CLIENT_MODES)[keyof typeof GATEWAY_CLIENT_MODES]; + +export type GatewayClientInfo = { + id: GatewayClientId; + displayName?: string; + version: string; + platform: string; + deviceFamily?: string; + modelIdentifier?: string; + mode: GatewayClientMode; + instanceId?: string; +}; + +const GATEWAY_CLIENT_ID_SET = new Set( + Object.values(GATEWAY_CLIENT_IDS), +); +const GATEWAY_CLIENT_MODE_SET = new Set( + Object.values(GATEWAY_CLIENT_MODES), +); + +export function normalizeGatewayClientId( + raw?: string | null, +): GatewayClientId | undefined { + const normalized = raw?.trim().toLowerCase(); + if (!normalized) return undefined; + return GATEWAY_CLIENT_ID_SET.has(normalized as GatewayClientId) + ? (normalized as GatewayClientId) + : undefined; +} + +export function normalizeGatewayClientName( + raw?: string | null, +): GatewayClientName | undefined { + return normalizeGatewayClientId(raw); +} + +export function normalizeGatewayClientMode( + raw?: string | null, +): GatewayClientMode | undefined { + const normalized = raw?.trim().toLowerCase(); + if (!normalized) return undefined; + return GATEWAY_CLIENT_MODE_SET.has(normalized as GatewayClientMode) + ? (normalized as GatewayClientMode) + : undefined; +} diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 748037442..525ddcbc4 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -86,8 +86,12 @@ import { type PresenceEntry, PresenceEntrySchema, ProtocolSchemas, + type ProvidersLogoutParams, + ProvidersLogoutParamsSchema, type ProvidersStatusParams, ProvidersStatusParamsSchema, + type ProvidersStatusResult, + ProvidersStatusResultSchema, type RequestFrame, RequestFrameSchema, type ResponseFrame, @@ -247,6 +251,9 @@ export const validateTalkModeParams = export const validateProvidersStatusParams = ajv.compile( ProvidersStatusParamsSchema, ); +export const validateProvidersLogoutParams = ajv.compile( + ProvidersLogoutParamsSchema, +); export const validateModelsListParams = ajv.compile( ModelsListParamsSchema, ); @@ -344,6 +351,8 @@ export { WizardStartResultSchema, WizardStatusResultSchema, ProvidersStatusParamsSchema, + ProvidersStatusResultSchema, + ProvidersLogoutParamsSchema, WebLoginStartParamsSchema, WebLoginWaitParamsSchema, AgentSummarySchema, @@ -409,6 +418,8 @@ export type { WizardStatusResult, TalkModeParams, ProvidersStatusParams, + ProvidersStatusResult, + ProvidersLogoutParams, WebLoginStartParams, WebLoginWaitParams, AgentSummary, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 784370791..1925c810c 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -1,5 +1,6 @@ import { type Static, type TSchema, Type } from "@sinclair/typebox"; import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js"; +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./client-info.js"; const NonEmptyString = Type.String({ minLength: 1 }); const SessionLabelString = Type.String({ @@ -7,6 +8,13 @@ const SessionLabelString = Type.String({ maxLength: SESSION_LABEL_MAX_LENGTH, }); +const GatewayClientIdSchema = Type.Union( + Object.values(GATEWAY_CLIENT_IDS).map((value) => Type.Literal(value)), +); +const GatewayClientModeSchema = Type.Union( + Object.values(GATEWAY_CLIENT_MODES).map((value) => Type.Literal(value)), +); + export const PresenceEntrySchema = Type.Object( { host: Type.Optional(NonEmptyString), @@ -69,12 +77,13 @@ export const ConnectParamsSchema = Type.Object( maxProtocol: Type.Integer({ minimum: 1 }), client: Type.Object( { - name: NonEmptyString, + id: GatewayClientIdSchema, + displayName: Type.Optional(NonEmptyString), version: NonEmptyString, platform: NonEmptyString, deviceFamily: Type.Optional(NonEmptyString), modelIdentifier: Type.Optional(NonEmptyString), - mode: NonEmptyString, + mode: GatewayClientModeSchema, instanceId: Type.Optional(NonEmptyString), }, { additionalProperties: false }, @@ -587,6 +596,68 @@ export const ProvidersStatusParamsSchema = Type.Object( { additionalProperties: false }, ); +// Provider docking: providers.status is intentionally schema-light so new +// providers can ship without protocol updates. +export const ProviderAccountSnapshotSchema = Type.Object( + { + accountId: NonEmptyString, + name: Type.Optional(Type.String()), + enabled: Type.Optional(Type.Boolean()), + configured: Type.Optional(Type.Boolean()), + linked: Type.Optional(Type.Boolean()), + running: Type.Optional(Type.Boolean()), + connected: Type.Optional(Type.Boolean()), + reconnectAttempts: Type.Optional(Type.Integer({ minimum: 0 })), + lastConnectedAt: Type.Optional(Type.Integer({ minimum: 0 })), + lastError: Type.Optional(Type.String()), + lastStartAt: Type.Optional(Type.Integer({ minimum: 0 })), + lastStopAt: Type.Optional(Type.Integer({ minimum: 0 })), + lastInboundAt: Type.Optional(Type.Integer({ minimum: 0 })), + lastOutboundAt: Type.Optional(Type.Integer({ minimum: 0 })), + lastProbeAt: Type.Optional(Type.Integer({ minimum: 0 })), + mode: Type.Optional(Type.String()), + dmPolicy: Type.Optional(Type.String()), + allowFrom: Type.Optional(Type.Array(Type.String())), + tokenSource: Type.Optional(Type.String()), + botTokenSource: Type.Optional(Type.String()), + appTokenSource: Type.Optional(Type.String()), + baseUrl: Type.Optional(Type.String()), + allowUnmentionedGroups: Type.Optional(Type.Boolean()), + cliPath: Type.Optional(Type.Union([Type.String(), Type.Null()])), + dbPath: Type.Optional(Type.Union([Type.String(), Type.Null()])), + port: Type.Optional( + Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]), + ), + probe: Type.Optional(Type.Unknown()), + audit: Type.Optional(Type.Unknown()), + application: Type.Optional(Type.Unknown()), + }, + { additionalProperties: true }, +); + +export const ProvidersStatusResultSchema = Type.Object( + { + ts: Type.Integer({ minimum: 0 }), + providerOrder: Type.Array(NonEmptyString), + providerLabels: Type.Record(NonEmptyString, NonEmptyString), + providers: Type.Record(NonEmptyString, Type.Unknown()), + providerAccounts: Type.Record( + NonEmptyString, + Type.Array(ProviderAccountSnapshotSchema), + ), + providerDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString), + }, + { additionalProperties: false }, +); + +export const ProvidersLogoutParamsSchema = Type.Object( + { + provider: NonEmptyString, + accountId: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + export const WebLoginStartParamsSchema = Type.Object( { force: Type.Optional(Type.Boolean()), @@ -718,15 +789,7 @@ export const CronPayloadSchema = Type.Union([ timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })), deliver: Type.Optional(Type.Boolean()), provider: Type.Optional( - Type.Union([ - Type.Literal("last"), - Type.Literal("whatsapp"), - Type.Literal("telegram"), - Type.Literal("discord"), - Type.Literal("slack"), - Type.Literal("signal"), - Type.Literal("imessage"), - ]), + Type.Union([Type.Literal("last"), NonEmptyString]), ), to: Type.Optional(Type.String()), bestEffortDeliver: Type.Optional(Type.Boolean()), @@ -975,6 +1038,8 @@ export const ProtocolSchemas: Record = { WizardStatusResult: WizardStatusResultSchema, TalkModeParams: TalkModeParamsSchema, ProvidersStatusParams: ProvidersStatusParamsSchema, + ProvidersStatusResult: ProvidersStatusResultSchema, + ProvidersLogoutParams: ProvidersLogoutParamsSchema, WebLoginStartParams: WebLoginStartParamsSchema, WebLoginWaitParams: WebLoginWaitParamsSchema, AgentSummary: AgentSummarySchema, @@ -1006,7 +1071,7 @@ export const ProtocolSchemas: Record = { ShutdownEvent: ShutdownEventSchema, }; -export const PROTOCOL_VERSION = 2 as const; +export const PROTOCOL_VERSION = 3 as const; export type ConnectParams = Static; export type HelloOk = Static; @@ -1052,6 +1117,8 @@ export type WizardStartResult = Static; export type WizardStatusResult = Static; export type TalkModeParams = Static; export type ProvidersStatusParams = Static; +export type ProvidersStatusResult = Static; +export type ProvidersLogoutParams = Static; export type WebLoginStartParams = Static; export type WebLoginWaitParams = Static; export type AgentSummary = Static; diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 87ac5a955..6f676d26d 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -34,6 +34,7 @@ import { setVoiceWakeTriggers, } from "../infra/voicewake.js"; import { clearCommandLane } from "../process/command-queue.js"; +import { normalizeProviderId } from "../providers/plugins/index.js"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { @@ -1100,14 +1101,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { const channelRaw = typeof link?.channel === "string" ? link.channel.trim() : ""; - const channel = channelRaw.toLowerCase(); - const provider = - channel === "whatsapp" || - channel === "telegram" || - channel === "signal" || - channel === "imessage" - ? channel - : undefined; + const provider = normalizeProviderId(channelRaw) ?? undefined; const to = typeof link?.to === "string" && link.to.trim() ? link.to.trim() diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 3184539be..386755492 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -9,6 +9,8 @@ import { saveSessionStore, } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; +import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; +import { DEFAULT_CHAT_PROVIDER } from "../../providers/registry.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -18,11 +20,6 @@ import { isGatewayMessageProvider, normalizeMessageProvider, } from "../../utils/message-provider.js"; -import { normalizeE164 } from "../../utils.js"; -import { - isWhatsAppGroupJid, - normalizeWhatsAppTarget, -} from "../../whatsapp/normalize.js"; import { parseMessageWithAttachments } from "../chat-attachments.js"; import { type AgentWaitParams, @@ -238,7 +235,9 @@ export const agentHandlers: GatewayRequestHandlers = { if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) { return lastProvider; } - return wantsDelivery ? "whatsapp" : INTERNAL_MESSAGE_PROVIDER; + return wantsDelivery + ? DEFAULT_CHAT_PROVIDER + : INTERNAL_MESSAGE_PROVIDER; } if (isGatewayMessageProvider(requestedProvider)) return requestedProvider; @@ -246,59 +245,35 @@ export const agentHandlers: GatewayRequestHandlers = { if (lastProvider && lastProvider !== INTERNAL_MESSAGE_PROVIDER) { return lastProvider; } - return wantsDelivery ? "whatsapp" : INTERNAL_MESSAGE_PROVIDER; + return wantsDelivery ? DEFAULT_CHAT_PROVIDER : INTERNAL_MESSAGE_PROVIDER; })(); - const resolvedTo = (() => { - const explicit = - typeof request.to === "string" && request.to.trim() - ? request.to.trim() - : undefined; - if (explicit) return explicit; - if (isDeliverableMessageProvider(resolvedProvider)) { - return lastTo || undefined; - } - return undefined; - })(); - - const sanitizedTo = (() => { - // If we derived a WhatsApp recipient from session "lastTo", ensure it is still valid - // for the configured allowlist. Otherwise, fall back to the first allowed number so - // voice wake doesn't silently route to stale/test recipients. - if (resolvedProvider !== "whatsapp") return resolvedTo; - const explicit = - typeof request.to === "string" && request.to.trim() - ? request.to.trim() - : undefined; - if (explicit) { - if (!resolvedTo) return resolvedTo; - return normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo; - } - if (resolvedTo && isWhatsAppGroupJid(resolvedTo)) { - return normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo; - } - + const explicitTo = + typeof request.to === "string" && request.to.trim() + ? request.to.trim() + : undefined; + const deliveryTargetMode = explicitTo + ? "explicit" + : isDeliverableMessageProvider(resolvedProvider) + ? "implicit" + : undefined; + let resolvedTo = + explicitTo || + (isDeliverableMessageProvider(resolvedProvider) + ? lastTo || undefined + : undefined); + if (!resolvedTo && isDeliverableMessageProvider(resolvedProvider)) { const cfg = cfgForAgent ?? loadConfig(); - const rawAllow = cfg.whatsapp?.allowFrom ?? []; - if (rawAllow.includes("*")) { - return resolvedTo - ? (normalizeWhatsAppTarget(resolvedTo) ?? resolvedTo) - : resolvedTo; + const fallback = resolveOutboundTarget({ + provider: resolvedProvider, + cfg, + accountId: sessionEntry?.lastAccountId ?? undefined, + mode: "implicit", + }); + if (fallback.ok) { + resolvedTo = fallback.to; } - const allowFrom = rawAllow - .map((val) => normalizeE164(val)) - .filter((val) => val.length > 1); - if (allowFrom.length === 0) return resolvedTo; - - const normalizedLast = - typeof resolvedTo === "string" && resolvedTo.trim() - ? normalizeWhatsAppTarget(resolvedTo) - : undefined; - if (normalizedLast && allowFrom.includes(normalizedLast)) { - return normalizedLast; - } - return allowFrom[0]; - })(); + } const deliver = request.deliver === true && @@ -321,11 +296,12 @@ export const agentHandlers: GatewayRequestHandlers = { { message, images, - to: sanitizedTo, + to: resolvedTo, sessionId: resolvedSessionId, sessionKey: requestedSessionKey, thinking: request.thinking, deliver, + deliveryTargetMode, provider: resolvedProvider, timeout: request.timeout?.toString(), bestEffortDeliver, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index c3afb65d4..ac9647182 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -7,6 +7,7 @@ import { mergeSessionEntry, saveSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js"; import { abortChatRunById, abortChatRunsForSessionKey, @@ -328,7 +329,7 @@ export const chatHandlers: GatewayRequestHandlers = { thinking: p.thinking, deliver: p.deliver, timeout: Math.ceil(timeoutMs / 1000).toString(), - messageProvider: "webchat", + messageProvider: INTERNAL_MESSAGE_PROVIDER, abortSignal: abortController.signal, }, defaultRuntime, diff --git a/src/gateway/server-methods/providers.ts b/src/gateway/server-methods/providers.ts index a56bee292..52af614b1 100644 --- a/src/gateway/server-methods/providers.ts +++ b/src/gateway/server-methods/providers.ts @@ -1,66 +1,80 @@ import type { ClawdbotConfig } from "../../config/config.js"; -import { - loadConfig, - readConfigFileSnapshot, - writeConfigFile, -} from "../../config/config.js"; -import type { TelegramGroupConfig } from "../../config/types.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "../../discord/accounts.js"; -import { - auditDiscordChannelPermissions, - collectDiscordAuditChannelIds, -} from "../../discord/audit.js"; -import { type DiscordProbe, probeDiscord } from "../../discord/probe.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "../../imessage/accounts.js"; -import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js"; +import { loadConfig, readConfigFileSnapshot } from "../../config/config.js"; import { getProviderActivity } from "../../infra/provider-activity.js"; +import { resolveProviderDefaultAccountId } from "../../providers/plugins/helpers.js"; import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "../../signal/accounts.js"; -import { probeSignal, type SignalProbe } from "../../signal/probe.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, -} from "../../slack/accounts.js"; -import { probeSlack, type SlackProbe } from "../../slack/probe.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "../../telegram/accounts.js"; -import { - auditTelegramGroupMembership, - collectTelegramUnmentionedGroupIds, -} from "../../telegram/audit.js"; -import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js"; -import { - listEnabledWhatsAppAccounts, - resolveDefaultWhatsAppAccountId, -} from "../../web/accounts.js"; -import { - getWebAuthAgeMs, - readWebSelfId, - webAuthExists, -} from "../../web/session.js"; + getProviderPlugin, + listProviderPlugins, + normalizeProviderId, + type ProviderId, +} from "../../providers/plugins/index.js"; +import { buildProviderAccountSnapshot } from "../../providers/plugins/status.js"; +import type { + ProviderAccountSnapshot, + ProviderPlugin, +} from "../../providers/plugins/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import { defaultRuntime } from "../../runtime.js"; import { ErrorCodes, errorShape, formatValidationErrors, + validateProvidersLogoutParams, validateProvidersStatusParams, } from "../protocol/index.js"; import { formatForLog } from "../ws-log.js"; -import type { GatewayRequestHandlers } from "./types.js"; +import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; + +type ProviderLogoutPayload = { + provider: ProviderId; + accountId: string; + cleared: boolean; + [key: string]: unknown; +}; + +export async function logoutProviderAccount(params: { + providerId: ProviderId; + accountId?: string | null; + cfg: ClawdbotConfig; + context: GatewayRequestContext; + plugin: ProviderPlugin; +}): Promise { + const resolvedAccountId = + params.accountId?.trim() || + params.plugin.config.defaultAccountId?.(params.cfg) || + params.plugin.config.listAccountIds(params.cfg)[0] || + DEFAULT_ACCOUNT_ID; + const account = params.plugin.config.resolveAccount( + params.cfg, + resolvedAccountId, + ); + await params.context.stopProvider(params.providerId, resolvedAccountId); + const result = await params.plugin.gateway?.logoutAccount?.({ + cfg: params.cfg, + accountId: resolvedAccountId, + account, + runtime: defaultRuntime, + }); + if (!result) { + throw new Error(`Provider ${params.providerId} does not support logout`); + } + const cleared = Boolean(result.cleared); + const loggedOut = + typeof result.loggedOut === "boolean" ? result.loggedOut : cleared; + if (loggedOut) { + params.context.markProviderLoggedOut( + params.providerId, + true, + resolvedAccountId, + ); + } + return { + provider: params.providerId, + accountId: resolvedAccountId, + ...result, + cleared, + }; +} export const providersHandlers: GatewayRequestHandlers = { "providers.status": async ({ params, respond, context }) => { @@ -81,457 +95,222 @@ export const providersHandlers: GatewayRequestHandlers = { typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000; const cfg = loadConfig(); const runtime = context.getRuntimeSnapshot(); + const plugins = listProviderPlugins(); + const pluginMap = new Map( + plugins.map((plugin) => [plugin.id, plugin]), + ); - const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg); - const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg); - const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg); - const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg); - const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg); + const resolveRuntimeSnapshot = ( + providerId: ProviderId, + accountId: string, + defaultAccountId: string, + ): ProviderAccountSnapshot | undefined => { + const accounts = runtime.providerAccounts[providerId]; + const defaultRuntime = runtime.providers[providerId]; + const raw = + accounts?.[accountId] ?? + (accountId === defaultAccountId ? defaultRuntime : undefined); + if (!raw) return undefined; + return raw; + }; - const telegramAccounts = await Promise.all( - listTelegramAccountIds(cfg).map(async (accountId) => { - const account = resolveTelegramAccount({ cfg, accountId }); - const rt = - runtime.telegramAccounts?.[account.accountId] ?? - (account.accountId === defaultTelegramAccountId - ? runtime.telegram - : undefined); - const configured = Boolean(account.token); - let telegramProbe: TelegramProbe | undefined; + const isAccountEnabled = (plugin: ProviderPlugin, account: unknown) => + plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : !account || + typeof account !== "object" || + (account as { enabled?: boolean }).enabled !== false; + + const buildProviderAccounts = async (providerId: ProviderId) => { + const plugin = pluginMap.get(providerId); + if (!plugin) { + return { + accounts: [] as ProviderAccountSnapshot[], + defaultAccountId: DEFAULT_ACCOUNT_ID, + defaultAccount: undefined as ProviderAccountSnapshot | undefined, + resolvedAccounts: {} as Record, + }; + } + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveProviderDefaultAccountId({ + plugin, + cfg, + accountIds, + }); + const accounts: ProviderAccountSnapshot[] = []; + const resolvedAccounts: Record = {}; + for (const accountId of accountIds) { + const account = plugin.config.resolveAccount(cfg, accountId); + const enabled = isAccountEnabled(plugin, account); + resolvedAccounts[accountId] = account; + let probeResult: unknown; let lastProbeAt: number | null = null; - const groups = - cfg.telegram?.accounts?.[account.accountId]?.groups ?? - cfg.telegram?.groups; - const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } = - collectTelegramUnmentionedGroupIds( - groups as Record | undefined, - ); - let audit: - | Awaited> - | undefined; - if (probe && configured && account.enabled) { - telegramProbe = await probeTelegram( - account.token, - timeoutMs, - account.config.proxy, - ); - lastProbeAt = Date.now(); - const botId = - telegramProbe.ok && telegramProbe.bot?.id != null - ? telegramProbe.bot.id - : null; - if (botId && (groupIds.length > 0 || unresolvedGroups > 0)) { - const auditRes = await auditTelegramGroupMembership({ - token: account.token, - botId, - groupIds, - proxyUrl: account.config.proxy, + if (probe && enabled && plugin.status?.probeAccount) { + let configured = true; + if (plugin.config.isConfigured) { + configured = await plugin.config.isConfigured(account, cfg); + } + if (configured) { + probeResult = await plugin.status.probeAccount({ + account, timeoutMs, + cfg, }); - audit = { - ...auditRes, - unresolvedGroups, - hasWildcardUnmentionedGroups, - }; - } else if (unresolvedGroups > 0 || hasWildcardUnmentionedGroups) { - audit = { - ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups, - checkedGroups: 0, - unresolvedGroups, - hasWildcardUnmentionedGroups, - groups: [], - elapsedMs: 0, - }; + lastProbeAt = Date.now(); } } - const allowUnmentionedGroups = - Boolean( - groups?.["*"] && - (groups["*"] as { requireMention?: boolean }).requireMention === - false, - ) || - Object.entries(groups ?? {}).some( - ([key, value]) => - key !== "*" && - Boolean(value) && - typeof value === "object" && - (value as { requireMention?: boolean }).requireMention === false, - ); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - tokenSource: account.tokenSource, - running: rt?.running ?? false, - mode: rt?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), - lastStartAt: rt?.lastStartAt ?? null, - lastStopAt: rt?.lastStopAt ?? null, - lastError: rt?.lastError ?? null, - probe: telegramProbe, - lastProbeAt, - audit, - allowUnmentionedGroups, - lastInboundAt: getProviderActivity({ - provider: "telegram", - accountId: account.accountId, - }).inboundAt, - lastOutboundAt: getProviderActivity({ - provider: "telegram", - accountId: account.accountId, - }).outboundAt, - }; - }), - ); - const defaultTelegramAccount = - telegramAccounts.find( - (account) => account.accountId === defaultTelegramAccountId, - ) ?? telegramAccounts[0]; - - const discordAccounts = await Promise.all( - listDiscordAccountIds(cfg).map(async (accountId) => { - const account = resolveDiscordAccount({ cfg, accountId }); - const rt = - runtime.discordAccounts?.[account.accountId] ?? - (account.accountId === defaultDiscordAccountId - ? runtime.discord - : undefined); - const configured = Boolean(account.token); - let discordProbe: DiscordProbe | undefined; - let lastProbeAt: number | null = null; - const { channelIds: auditChannelIds, unresolvedChannels } = - collectDiscordAuditChannelIds({ cfg, accountId: account.accountId }); - let audit: - | Awaited> - | undefined; - if (probe && configured && account.enabled) { - discordProbe = await probeDiscord(account.token, timeoutMs, { - includeApplication: true, - }); - lastProbeAt = Date.now(); - if (auditChannelIds.length > 0 || unresolvedChannels > 0) { - const auditRes = await auditDiscordChannelPermissions({ - token: account.token, - accountId: account.accountId, - channelIds: auditChannelIds, + let auditResult: unknown; + if (probe && enabled && plugin.status?.auditAccount) { + let configured = true; + if (plugin.config.isConfigured) { + configured = await plugin.config.isConfigured(account, cfg); + } + if (configured) { + auditResult = await plugin.status.auditAccount({ + account, timeoutMs, + cfg, + probe: probeResult, }); - audit = { ...auditRes, unresolvedChannels }; } } - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - tokenSource: account.tokenSource, - bot: rt?.bot ?? null, - application: rt?.application ?? null, - running: rt?.running ?? false, - lastStartAt: rt?.lastStartAt ?? null, - lastStopAt: rt?.lastStopAt ?? null, - lastError: rt?.lastError ?? null, - probe: discordProbe, - lastProbeAt, - audit, - lastInboundAt: getProviderActivity({ - provider: "discord", - accountId: account.accountId, - }).inboundAt, - lastOutboundAt: getProviderActivity({ - provider: "discord", - accountId: account.accountId, - }).outboundAt, - }; - }), - ); - const defaultDiscordAccount = - discordAccounts.find( - (account) => account.accountId === defaultDiscordAccountId, - ) ?? discordAccounts[0]; - - const slackAccounts = await Promise.all( - listSlackAccountIds(cfg).map(async (accountId) => { - const account = resolveSlackAccount({ cfg, accountId }); - const rt = - runtime.slackAccounts?.[account.accountId] ?? - (account.accountId === defaultSlackAccountId - ? runtime.slack - : undefined); - const configured = Boolean(account.botToken && account.appToken); - let slackProbe: SlackProbe | undefined; - let lastProbeAt: number | null = null; - if (probe && configured && account.enabled && account.botToken) { - slackProbe = await probeSlack(account.botToken, timeoutMs); - lastProbeAt = Date.now(); - } - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - running: rt?.running ?? false, - lastStartAt: rt?.lastStartAt ?? null, - lastStopAt: rt?.lastStopAt ?? null, - lastError: rt?.lastError ?? null, - probe: slackProbe, - lastProbeAt, - }; - }), - ); - const defaultSlackAccount = - slackAccounts.find( - (account) => account.accountId === defaultSlackAccountId, - ) ?? slackAccounts[0]; - - const signalAccounts = await Promise.all( - listSignalAccountIds(cfg).map(async (accountId) => { - const account = resolveSignalAccount({ cfg, accountId }); - const rt = - runtime.signalAccounts?.[account.accountId] ?? - (account.accountId === defaultSignalAccountId - ? runtime.signal - : undefined); - const configured = account.configured; - let signalProbe: SignalProbe | undefined; - let lastProbeAt: number | null = null; - if (probe && configured && account.enabled) { - signalProbe = await probeSignal(account.baseUrl, timeoutMs); - lastProbeAt = Date.now(); - } - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - baseUrl: account.baseUrl, - running: rt?.running ?? false, - lastStartAt: rt?.lastStartAt ?? null, - lastStopAt: rt?.lastStopAt ?? null, - lastError: rt?.lastError ?? null, - probe: signalProbe, - lastProbeAt, - }; - }), - ); - const defaultSignalAccount = - signalAccounts.find( - (account) => account.accountId === defaultSignalAccountId, - ) ?? signalAccounts[0]; - - const imessageBaseConfigured = Boolean(cfg.imessage); - let imessageProbe: IMessageProbe | undefined; - let imessageLastProbeAt: number | null = null; - if (probe && imessageBaseConfigured) { - imessageProbe = await probeIMessage(timeoutMs); - imessageLastProbeAt = Date.now(); - } - const imessageAccounts = listIMessageAccountIds(cfg).map((accountId) => { - const account = resolveIMessageAccount({ cfg, accountId }); - const rt = - runtime.imessageAccounts?.[account.accountId] ?? - (account.accountId === defaultIMessageAccountId - ? runtime.imessage - : undefined); - return { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: imessageBaseConfigured, - running: rt?.running ?? false, - lastStartAt: rt?.lastStartAt ?? null, - lastStopAt: rt?.lastStopAt ?? null, - lastError: rt?.lastError ?? null, - cliPath: rt?.cliPath ?? account.config.cliPath ?? null, - dbPath: rt?.dbPath ?? account.config.dbPath ?? null, - probe: imessageProbe, - lastProbeAt: imessageLastProbeAt, - }; - }); - const defaultIMessageAccount = - imessageAccounts.find( - (account) => account.accountId === defaultIMessageAccountId, - ) ?? imessageAccounts[0]; - const defaultWhatsAppAccountId = resolveDefaultWhatsAppAccountId(cfg); - const enabledWhatsAppAccounts = listEnabledWhatsAppAccounts(cfg); - const defaultWhatsAppAccount = - enabledWhatsAppAccounts.find( - (account) => account.accountId === defaultWhatsAppAccountId, - ) ?? enabledWhatsAppAccounts[0]; - const linked = defaultWhatsAppAccount - ? await webAuthExists(defaultWhatsAppAccount.authDir) - : false; - const authAgeMs = defaultWhatsAppAccount - ? getWebAuthAgeMs(defaultWhatsAppAccount.authDir) - : null; - const self = defaultWhatsAppAccount - ? readWebSelfId(defaultWhatsAppAccount.authDir) - : { e164: null, jid: null }; - - const defaultWhatsAppStatus = { - running: false, - connected: false, - reconnectAttempts: 0, - lastConnectedAt: null, - lastDisconnect: null, - lastMessageAt: null, - lastEventAt: null, - lastError: null, - } as const; - const whatsappAccounts = await Promise.all( - enabledWhatsAppAccounts.map(async (account) => { - const rt = - runtime.whatsappAccounts?.[account.accountId] ?? - defaultWhatsAppStatus; - return { - accountId: account.accountId, - enabled: account.enabled, - linked: await webAuthExists(account.authDir), - authAgeMs: getWebAuthAgeMs(account.authDir), - self: readWebSelfId(account.authDir), - running: rt.running, - connected: rt.connected, - lastConnectedAt: rt.lastConnectedAt ?? null, - lastDisconnect: rt.lastDisconnect ?? null, - reconnectAttempts: rt.reconnectAttempts, - lastMessageAt: rt.lastMessageAt ?? null, - lastEventAt: rt.lastEventAt ?? null, - lastError: rt.lastError ?? null, - lastInboundAt: getProviderActivity({ - provider: "whatsapp", - accountId: account.accountId, - }).inboundAt, - lastOutboundAt: getProviderActivity({ - provider: "whatsapp", - accountId: account.accountId, - }).outboundAt, - }; - }), - ); - - respond( - true, - { - ts: Date.now(), - whatsapp: { - configured: linked, - linked, - authAgeMs, - self, - running: runtime.whatsapp.running, - connected: runtime.whatsapp.connected, - lastConnectedAt: runtime.whatsapp.lastConnectedAt ?? null, - lastDisconnect: runtime.whatsapp.lastDisconnect ?? null, - reconnectAttempts: runtime.whatsapp.reconnectAttempts, - lastMessageAt: runtime.whatsapp.lastMessageAt ?? null, - lastEventAt: runtime.whatsapp.lastEventAt ?? null, - lastError: runtime.whatsapp.lastError ?? null, - }, - whatsappAccounts, - whatsappDefaultAccountId: defaultWhatsAppAccountId, - telegram: { - configured: defaultTelegramAccount?.configured ?? false, - tokenSource: defaultTelegramAccount?.tokenSource ?? "none", - running: defaultTelegramAccount?.running ?? false, - mode: defaultTelegramAccount?.mode ?? null, - lastStartAt: defaultTelegramAccount?.lastStartAt ?? null, - lastStopAt: defaultTelegramAccount?.lastStopAt ?? null, - lastError: defaultTelegramAccount?.lastError ?? null, - probe: defaultTelegramAccount?.probe, - lastProbeAt: defaultTelegramAccount?.lastProbeAt ?? null, - }, - telegramAccounts, - telegramDefaultAccountId: defaultTelegramAccountId, - discord: { - configured: defaultDiscordAccount?.configured ?? false, - tokenSource: defaultDiscordAccount?.tokenSource ?? "none", - running: defaultDiscordAccount?.running ?? false, - lastStartAt: defaultDiscordAccount?.lastStartAt ?? null, - lastStopAt: defaultDiscordAccount?.lastStopAt ?? null, - lastError: defaultDiscordAccount?.lastError ?? null, - probe: defaultDiscordAccount?.probe, - lastProbeAt: defaultDiscordAccount?.lastProbeAt ?? null, - }, - discordAccounts, - discordDefaultAccountId: defaultDiscordAccountId, - slack: { - configured: defaultSlackAccount?.configured ?? false, - botTokenSource: defaultSlackAccount?.botTokenSource ?? "none", - appTokenSource: defaultSlackAccount?.appTokenSource ?? "none", - running: defaultSlackAccount?.running ?? false, - lastStartAt: defaultSlackAccount?.lastStartAt ?? null, - lastStopAt: defaultSlackAccount?.lastStopAt ?? null, - lastError: defaultSlackAccount?.lastError ?? null, - probe: defaultSlackAccount?.probe, - lastProbeAt: defaultSlackAccount?.lastProbeAt ?? null, - }, - slackAccounts, - slackDefaultAccountId: defaultSlackAccountId, - signal: { - configured: defaultSignalAccount?.configured ?? false, - baseUrl: defaultSignalAccount?.baseUrl ?? null, - running: defaultSignalAccount?.running ?? false, - lastStartAt: defaultSignalAccount?.lastStartAt ?? null, - lastStopAt: defaultSignalAccount?.lastStopAt ?? null, - lastError: defaultSignalAccount?.lastError ?? null, - probe: defaultSignalAccount?.probe, - lastProbeAt: defaultSignalAccount?.lastProbeAt ?? null, - }, - signalAccounts, - signalDefaultAccountId: defaultSignalAccountId, - imessage: { - configured: defaultIMessageAccount?.configured ?? false, - running: defaultIMessageAccount?.running ?? false, - lastStartAt: defaultIMessageAccount?.lastStartAt ?? null, - lastStopAt: defaultIMessageAccount?.lastStopAt ?? null, - lastError: defaultIMessageAccount?.lastError ?? null, - cliPath: defaultIMessageAccount?.cliPath ?? null, - dbPath: defaultIMessageAccount?.dbPath ?? null, - probe: defaultIMessageAccount?.probe, - lastProbeAt: defaultIMessageAccount?.lastProbeAt ?? null, - }, - imessageAccounts, - imessageDefaultAccountId: defaultIMessageAccountId, - }, - undefined, - ); - }, - "telegram.logout": async ({ respond, context }) => { - try { - await context.stopTelegramProvider(); - const snapshot = await readConfigFileSnapshot(); - if (!snapshot.valid) { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "config invalid; fix it before logging out", - ), + const runtimeSnapshot = resolveRuntimeSnapshot( + providerId, + accountId, + defaultAccountId, ); - return; + const snapshot = await buildProviderAccountSnapshot({ + plugin, + cfg, + accountId, + runtime: runtimeSnapshot, + probe: probeResult, + audit: auditResult, + }); + if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt; + const activity = getProviderActivity({ + provider: providerId as never, + accountId, + }); + if (snapshot.lastInboundAt == null) { + snapshot.lastInboundAt = activity.inboundAt; + } + if (snapshot.lastOutboundAt == null) { + snapshot.lastOutboundAt = activity.outboundAt; + } + accounts.push(snapshot); } - const cfg = snapshot.config ?? {}; - const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; - const hadToken = Boolean(cfg.telegram?.botToken); - const nextTelegram = cfg.telegram ? { ...cfg.telegram } : undefined; - if (nextTelegram) { - delete nextTelegram.botToken; - } - const nextCfg = { ...cfg } as ClawdbotConfig; - if (nextTelegram && Object.keys(nextTelegram).length > 0) { - nextCfg.telegram = nextTelegram; - } else { - delete nextCfg.telegram; - } - await writeConfigFile(nextCfg); + const defaultAccount = + accounts.find((entry) => entry.accountId === defaultAccountId) ?? + accounts[0]; + return { accounts, defaultAccountId, defaultAccount, resolvedAccounts }; + }; + + const payload: Record = { + ts: Date.now(), + providerOrder: plugins.map((plugin) => plugin.id), + providerLabels: Object.fromEntries( + plugins.map((plugin) => [plugin.id, plugin.meta.label]), + ), + providers: {} as Record, + providerAccounts: {} as Record, + providerDefaultAccountId: {} as Record, + }; + const providersMap = payload.providers as Record; + const accountsMap = payload.providerAccounts as Record; + const defaultAccountIdMap = payload.providerDefaultAccountId as Record< + string, + unknown + >; + for (const plugin of plugins) { + const { accounts, defaultAccountId, defaultAccount, resolvedAccounts } = + await buildProviderAccounts(plugin.id); + const fallbackAccount = + resolvedAccounts[defaultAccountId] ?? + plugin.config.resolveAccount(cfg, defaultAccountId); + const summary = plugin.status?.buildProviderSummary + ? await plugin.status.buildProviderSummary({ + account: fallbackAccount, + cfg, + defaultAccountId, + snapshot: + defaultAccount ?? + ({ + accountId: defaultAccountId, + } as ProviderAccountSnapshot), + }) + : { + configured: defaultAccount?.configured ?? false, + }; + providersMap[plugin.id] = summary; + accountsMap[plugin.id] = accounts; + defaultAccountIdMap[plugin.id] = defaultAccountId; + } + + respond(true, payload, undefined); + }, + "providers.logout": async ({ params, respond, context }) => { + if (!validateProvidersLogoutParams(params)) { respond( - true, - { cleared: hadToken, envToken: Boolean(envToken) }, + false, undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid providers.logout params: ${formatValidationErrors(validateProvidersLogoutParams.errors)}`, + ), ); + return; + } + const rawProvider = (params as { provider?: unknown }).provider; + const providerId = + typeof rawProvider === "string" ? normalizeProviderId(rawProvider) : null; + if (!providerId) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "invalid providers.logout provider", + ), + ); + return; + } + const plugin = getProviderPlugin(providerId); + if (!plugin?.gateway?.logoutAccount) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `provider ${providerId} does not support logout`, + ), + ); + return; + } + const accountIdRaw = (params as { accountId?: unknown }).accountId; + const accountId = + typeof accountIdRaw === "string" ? accountIdRaw.trim() : undefined; + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "config invalid; fix it before logging out", + ), + ); + return; + } + try { + const payload = await logoutProviderAccount({ + providerId, + accountId, + cfg: snapshot.config ?? {}, + context, + plugin, + }); + respond(true, payload, undefined); } catch (err) { respond( false, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 059e0452e..553f8666b 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -1,16 +1,14 @@ import { loadConfig } from "../../config/config.js"; -import { sendMessageDiscord, sendPollDiscord } from "../../discord/index.js"; -import { shouldLogVerbose } from "../../globals.js"; -import { sendMessageIMessage } from "../../imessage/index.js"; -import { createMSTeamsPollStoreFs } from "../../msteams/polls.js"; -import { sendMessageMSTeams, sendPollMSTeams } from "../../msteams/send.js"; +import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; +import type { OutboundProvider } from "../../infra/outbound/targets.js"; +import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { normalizePollInput } from "../../polls.js"; -import { sendMessageSignal } from "../../signal/index.js"; -import { sendMessageSlack } from "../../slack/send.js"; -import { sendMessageTelegram } from "../../telegram/send.js"; -import { normalizeMessageProvider } from "../../utils/message-provider.js"; -import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js"; -import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../../providers/plugins/index.js"; +import type { ProviderId } from "../../providers/plugins/types.js"; +import { DEFAULT_CHAT_PROVIDER } from "../../providers/registry.js"; import { ErrorCodes, errorShape, @@ -54,139 +52,86 @@ export const sendHandlers: GatewayRequestHandlers = { } const to = request.to.trim(); const message = request.message.trim(); - const provider = normalizeMessageProvider(request.provider) ?? "whatsapp"; + const providerInput = + typeof request.provider === "string" ? request.provider : undefined; + const normalizedProvider = providerInput + ? normalizeProviderId(providerInput) + : null; + if (providerInput && !normalizedProvider) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `unsupported provider: ${providerInput}`, + ), + ); + return; + } + const provider = normalizedProvider ?? DEFAULT_CHAT_PROVIDER; const accountId = typeof request.accountId === "string" && request.accountId.trim().length ? request.accountId.trim() : undefined; try { - if (provider === "telegram") { - const result = await sendMessageTelegram(to, message, { - mediaUrl: request.mediaUrl, - verbose: shouldLogVerbose(), - accountId, - }); - const payload = { - runId: idem, - messageId: result.messageId, - chatId: result.chatId, - provider, - }; - context.dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "discord") { - const result = await sendMessageDiscord(to, message, { - mediaUrl: request.mediaUrl, - accountId, - }); - const payload = { - runId: idem, - messageId: result.messageId, - channelId: result.channelId, - provider, - }; - context.dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "slack") { - const result = await sendMessageSlack(to, message, { - mediaUrl: request.mediaUrl, - accountId, - }); - const payload = { - runId: idem, - messageId: result.messageId, - channelId: result.channelId, - provider, - }; - context.dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "signal") { - const result = await sendMessageSignal(to, message, { - mediaUrl: request.mediaUrl, - accountId, - }); - const payload = { - runId: idem, - messageId: result.messageId, - provider, - }; - context.dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "imessage") { - const result = await sendMessageIMessage(to, message, { - mediaUrl: request.mediaUrl, - accountId, - }); - const payload = { - runId: idem, - messageId: result.messageId, - provider, - }; - context.dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "msteams") { - const cfg = loadConfig(); - const result = await sendMessageMSTeams({ - cfg, - to, - text: message, - mediaUrl: request.mediaUrl, - }); - const payload = { - runId: idem, - messageId: result.messageId, - conversationId: result.conversationId, - provider, - }; - context.dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else { - const cfg = loadConfig(); - const targetAccountId = - accountId ?? resolveDefaultWhatsAppAccountId(cfg); - const result = await sendMessageWhatsApp(to, message, { - mediaUrl: request.mediaUrl, - verbose: shouldLogVerbose(), - gifPlayback: request.gifPlayback, - accountId: targetAccountId, - }); - const payload = { - runId: idem, - messageId: result.messageId, - toJid: result.toJid ?? `${to}@s.whatsapp.net`, - provider, - }; - context.dedupe.set(`send:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); + const outboundProvider = provider as Exclude; + const plugin = getProviderPlugin(provider as ProviderId); + if (!plugin) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `unsupported provider: ${provider}`, + ), + ); + return; } + const cfg = loadConfig(); + const resolved = resolveOutboundTarget({ + provider: outboundProvider, + to, + cfg, + accountId, + mode: "explicit", + }); + if (!resolved.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)), + ); + return; + } + const results = await deliverOutboundPayloads({ + cfg, + provider: outboundProvider, + to: resolved.to, + accountId, + payloads: [{ text: message, mediaUrl: request.mediaUrl }], + gifPlayback: request.gifPlayback, + }); + const result = results.at(-1); + if (!result) { + throw new Error("No delivery result"); + } + const payload: Record = { + runId: idem, + messageId: result.messageId, + provider, + }; + if ("chatId" in result) payload.chatId = result.chatId; + if ("channelId" in result) payload.channelId = result.channelId; + if ("toJid" in result) payload.toJid = result.toJid; + if ("conversationId" in result) { + payload.conversationId = result.conversationId; + } + context.dedupe.set(`send:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); } catch (err) { const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); context.dedupe.set(`send:${idem}`, { @@ -232,22 +177,23 @@ export const sendHandlers: GatewayRequestHandlers = { return; } const to = request.to.trim(); - const provider = normalizeMessageProvider(request.provider) ?? "whatsapp"; - if ( - provider !== "whatsapp" && - provider !== "discord" && - provider !== "msteams" - ) { + const providerInput = + typeof request.provider === "string" ? request.provider : undefined; + const normalizedProvider = providerInput + ? normalizeProviderId(providerInput) + : null; + if (providerInput && !normalizedProvider) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, - `unsupported poll provider: ${provider}`, + `unsupported poll provider: ${providerInput}`, ), ); return; } + const provider = normalizedProvider ?? DEFAULT_CHAT_PROVIDER; const poll = { question: request.question, options: request.options, @@ -259,78 +205,59 @@ export const sendHandlers: GatewayRequestHandlers = { ? request.accountId.trim() : undefined; try { - if (provider === "discord") { - const result = await sendPollDiscord(to, poll, { accountId }); - const payload = { - runId: idem, - messageId: result.messageId, - channelId: result.channelId, - provider, - }; - context.dedupe.set(`poll:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else if (provider === "msteams") { - const cfg = loadConfig(); - const normalized = normalizePollInput(poll, { maxOptions: 12 }); - const result = await sendPollMSTeams({ - cfg, - to, - question: normalized.question, - options: normalized.options, - maxSelections: normalized.maxSelections, - }); - const pollStore = createMSTeamsPollStoreFs(); - await pollStore.createPoll({ - id: result.pollId, - question: normalized.question, - options: normalized.options, - maxSelections: normalized.maxSelections, - createdAt: new Date().toISOString(), - conversationId: result.conversationId, - messageId: result.messageId, - votes: {}, - }); - const payload = { - runId: idem, - messageId: result.messageId, - conversationId: result.conversationId, - pollId: result.pollId, - provider, - }; - context.dedupe.set(`poll:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); - } else { - const cfg = loadConfig(); - const accountId = - typeof request.accountId === "string" && - request.accountId.trim().length > 0 - ? request.accountId.trim() - : resolveDefaultWhatsAppAccountId(cfg); - const result = await sendPollWhatsApp(to, poll, { - verbose: shouldLogVerbose(), - accountId, - }); - const payload = { - runId: idem, - messageId: result.messageId, - toJid: result.toJid ?? `${to}@s.whatsapp.net`, - provider, - }; - context.dedupe.set(`poll:${idem}`, { - ts: Date.now(), - ok: true, - payload, - }); - respond(true, payload, undefined, { provider }); + const plugin = getProviderPlugin(provider as ProviderId); + const outbound = plugin?.outbound; + if (!outbound?.sendPoll) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `unsupported poll provider: ${provider}`, + ), + ); + return; } + const cfg = loadConfig(); + const resolved = resolveOutboundTarget({ + provider: provider as Exclude, + to, + cfg, + accountId, + mode: "explicit", + }); + if (!resolved.ok) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)), + ); + return; + } + const normalized = outbound.pollMaxOptions + ? normalizePollInput(poll, { maxOptions: outbound.pollMaxOptions }) + : normalizePollInput(poll); + const result = await outbound.sendPoll({ + cfg, + to: resolved.to, + poll: normalized, + accountId, + }); + const payload: Record = { + runId: idem, + messageId: result.messageId, + provider, + }; + if (result.toJid) payload.toJid = result.toJid; + if (result.channelId) payload.channelId = result.channelId; + if (result.conversationId) payload.conversationId = result.conversationId; + if (result.pollId) payload.pollId = result.pollId; + context.dedupe.set(`poll:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); } catch (err) { const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); context.dedupe.set(`poll:${idem}`, { diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index 613faa32a..0683a09db 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -69,19 +69,19 @@ export type GatewayRequestContext = { findRunningWizard: () => string | null; purgeWizardSession: (id: string) => void; getRuntimeSnapshot: () => ProviderRuntimeSnapshot; - startWhatsAppProvider: (accountId?: string) => Promise; - stopWhatsAppProvider: (accountId?: string) => Promise; - startTelegramProvider: (accountId?: string) => Promise; - stopTelegramProvider: (accountId?: string) => Promise; - startDiscordProvider: (accountId?: string) => Promise; - stopDiscordProvider: (accountId?: string) => Promise; - startSlackProvider: (accountId?: string) => Promise; - stopSlackProvider: (accountId?: string) => Promise; - startSignalProvider: (accountId?: string) => Promise; - stopSignalProvider: (accountId?: string) => Promise; - startIMessageProvider: (accountId?: string) => Promise; - stopIMessageProvider: (accountId?: string) => Promise; - markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void; + startProvider: ( + provider: import("../../providers/plugins/types.js").ProviderId, + accountId?: string, + ) => Promise; + stopProvider: ( + provider: import("../../providers/plugins/types.js").ProviderId, + accountId?: string, + ) => Promise; + markProviderLoggedOut: ( + providerId: import("../../providers/plugins/types.js").ProviderId, + cleared: boolean, + accountId?: string, + ) => void; wizardRunner: ( opts: import("../../commands/onboard-types.js").OnboardOptions, runtime: import("../../runtime.js").RuntimeEnv, diff --git a/src/gateway/server-methods/web.ts b/src/gateway/server-methods/web.ts index 9fa9c8b7f..4d3a3a6bc 100644 --- a/src/gateway/server-methods/web.ts +++ b/src/gateway/server-methods/web.ts @@ -1,8 +1,4 @@ -import { loadConfig } from "../../config/config.js"; -import { defaultRuntime } from "../../runtime.js"; -import { resolveWhatsAppAccount } from "../../web/accounts.js"; -import { startWebLoginWithQr, waitForWebLogin } from "../../web/login-qr.js"; -import { logoutWeb } from "../../web/session.js"; +import { listProviderPlugins } from "../../providers/plugins/index.js"; import { ErrorCodes, errorShape, @@ -13,6 +9,15 @@ import { import { formatForLog } from "../ws-log.js"; import type { GatewayRequestHandlers } from "./types.js"; +const WEB_LOGIN_METHODS = new Set(["web.login.start", "web.login.wait"]); + +const resolveWebLoginProvider = () => + listProviderPlugins().find((plugin) => + (plugin.gatewayMethods ?? []).some((method) => + WEB_LOGIN_METHODS.has(method), + ), + ) ?? null; + export const webHandlers: GatewayRequestHandlers = { "web.login.start": async ({ params, respond, context }) => { if (!validateWebLoginStartParams(params)) { @@ -31,8 +36,31 @@ export const webHandlers: GatewayRequestHandlers = { typeof (params as { accountId?: unknown }).accountId === "string" ? (params as { accountId?: string }).accountId : undefined; - await context.stopWhatsAppProvider(accountId); - const result = await startWebLoginWithQr({ + const provider = resolveWebLoginProvider(); + if (!provider) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "web login provider is not available", + ), + ); + return; + } + await context.stopProvider(provider.id, accountId); + if (!provider.gateway?.loginWithQrStart) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `web login is not supported by provider ${provider.id}`, + ), + ); + return; + } + const result = await provider.gateway.loginWithQrStart({ force: Boolean((params as { force?: boolean }).force), timeoutMs: typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" @@ -67,7 +95,30 @@ export const webHandlers: GatewayRequestHandlers = { typeof (params as { accountId?: unknown }).accountId === "string" ? (params as { accountId?: string }).accountId : undefined; - const result = await waitForWebLogin({ + const provider = resolveWebLoginProvider(); + if (!provider) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "web login provider is not available", + ), + ); + return; + } + if (!provider.gateway?.loginWithQrWait) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `web login is not supported by provider ${provider.id}`, + ), + ); + return; + } + const result = await provider.gateway.loginWithQrWait({ timeoutMs: typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" ? (params as { timeoutMs?: number }).timeoutMs @@ -75,7 +126,7 @@ export const webHandlers: GatewayRequestHandlers = { accountId, }); if (result.connected) { - await context.startWhatsAppProvider(accountId); + await context.startProvider(provider.id, accountId); } respond(true, result, undefined); } catch (err) { @@ -86,33 +137,4 @@ export const webHandlers: GatewayRequestHandlers = { ); } }, - "web.logout": async ({ params, respond, context }) => { - try { - const rawAccountId = - params && typeof params === "object" && "accountId" in params - ? (params as { accountId?: unknown }).accountId - : undefined; - const accountId = - typeof rawAccountId === "string" ? rawAccountId.trim() : ""; - const cfg = loadConfig(); - const account = resolveWhatsAppAccount({ - cfg, - accountId: accountId || undefined, - }); - await context.stopWhatsAppProvider(account.accountId); - const cleared = await logoutWeb({ - authDir: account.authDir, - isLegacyAuthDir: account.isLegacyAuthDir, - runtime: defaultRuntime, - }); - context.markWhatsAppLoggedOut(cleared, account.accountId); - respond(true, { cleared }, undefined); - } catch (err) { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)), - ); - } - }, }; diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts index c13016ecf..3ac471710 100644 --- a/src/gateway/server-providers.ts +++ b/src/gateway/server-providers.ts @@ -1,679 +1,241 @@ import type { ClawdbotConfig } from "../config/config.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "../discord/accounts.js"; -import { monitorDiscordProvider } from "../discord/index.js"; -import type { - DiscordApplicationSummary, - DiscordProbe, -} from "../discord/probe.js"; -import { probeDiscord } from "../discord/probe.js"; -import { shouldLogVerbose } from "../globals.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "../imessage/accounts.js"; -import { monitorIMessageProvider } from "../imessage/index.js"; +import { formatErrorMessage } from "../infra/errors.js"; import type { createSubsystemLogger } from "../logging.js"; -import { monitorWebProvider, webAuthExists } from "../providers/web/index.js"; +import { resolveProviderDefaultAccountId } from "../providers/plugins/helpers.js"; +import { + getProviderPlugin, + listProviderPlugins, + type ProviderId, +} from "../providers/plugins/index.js"; +import type { ProviderAccountSnapshot } from "../providers/plugins/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "../signal/accounts.js"; -import { monitorSignalProvider } from "../signal/index.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, -} from "../slack/accounts.js"; -import { monitorSlackProvider } from "../slack/index.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "../telegram/accounts.js"; -import { monitorTelegramProvider } from "../telegram/monitor.js"; -import { probeTelegram } from "../telegram/probe.js"; -import { - listEnabledWhatsAppAccounts, - resolveDefaultWhatsAppAccountId, -} from "../web/accounts.js"; -import type { WebProviderStatus } from "../web/auto-reply.js"; -import { readWebSelfId } from "../web/session.js"; -import { formatError } from "./server-utils.js"; - -export type TelegramRuntimeStatus = { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - mode?: "webhook" | "polling" | null; -}; - -export type DiscordRuntimeStatus = { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - bot?: DiscordProbe["bot"]; - application?: DiscordApplicationSummary; -}; - -export type SlackRuntimeStatus = { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; -}; - -export type SignalRuntimeStatus = { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - baseUrl?: string | null; -}; - -export type IMessageRuntimeStatus = { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - cliPath?: string | null; - dbPath?: string | null; -}; - -export type MSTeamsRuntimeStatus = { - running: boolean; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - port?: number | null; -}; export type ProviderRuntimeSnapshot = { - whatsapp: WebProviderStatus; - whatsappAccounts?: Record; - telegram: TelegramRuntimeStatus; - telegramAccounts?: Record; - discord: DiscordRuntimeStatus; - discordAccounts?: Record; - slack: SlackRuntimeStatus; - slackAccounts?: Record; - signal: SignalRuntimeStatus; - signalAccounts?: Record; - imessage: IMessageRuntimeStatus; - imessageAccounts?: Record; - msteams: MSTeamsRuntimeStatus; + providers: Partial>; + providerAccounts: Partial< + Record> + >; }; type SubsystemLogger = ReturnType; +type ProviderRuntimeStore = { + aborts: Map; + tasks: Map>; + runtimes: Map; +}; + +function createRuntimeStore(): ProviderRuntimeStore { + return { + aborts: new Map(), + tasks: new Map(), + runtimes: new Map(), + }; +} + +function isAccountEnabled(account: unknown): boolean { + if (!account || typeof account !== "object") return true; + const enabled = (account as { enabled?: boolean }).enabled; + return enabled !== false; +} + +function resolveDefaultRuntime( + providerId: ProviderId, +): ProviderAccountSnapshot { + const plugin = getProviderPlugin(providerId); + return plugin?.status?.defaultRuntime ?? { accountId: DEFAULT_ACCOUNT_ID }; +} + +function cloneDefaultRuntime( + providerId: ProviderId, + accountId: string, +): ProviderAccountSnapshot { + return { ...resolveDefaultRuntime(providerId), accountId }; +} + type ProviderManagerOptions = { loadConfig: () => ClawdbotConfig; - logWhatsApp: SubsystemLogger; - logTelegram: SubsystemLogger; - logDiscord: SubsystemLogger; - logSlack: SubsystemLogger; - logSignal: SubsystemLogger; - logIMessage: SubsystemLogger; - logMSTeams: SubsystemLogger; - whatsappRuntimeEnv: RuntimeEnv; - telegramRuntimeEnv: RuntimeEnv; - discordRuntimeEnv: RuntimeEnv; - slackRuntimeEnv: RuntimeEnv; - signalRuntimeEnv: RuntimeEnv; - imessageRuntimeEnv: RuntimeEnv; - msteamsRuntimeEnv: RuntimeEnv; + providerLogs: Record; + providerRuntimeEnvs: Record; }; export type ProviderManager = { getRuntimeSnapshot: () => ProviderRuntimeSnapshot; startProviders: () => Promise; - startWhatsAppProvider: (accountId?: string) => Promise; - stopWhatsAppProvider: (accountId?: string) => Promise; - startTelegramProvider: (accountId?: string) => Promise; - stopTelegramProvider: (accountId?: string) => Promise; - startDiscordProvider: (accountId?: string) => Promise; - stopDiscordProvider: (accountId?: string) => Promise; - startSlackProvider: (accountId?: string) => Promise; - stopSlackProvider: (accountId?: string) => Promise; - startSignalProvider: (accountId?: string) => Promise; - stopSignalProvider: (accountId?: string) => Promise; - startIMessageProvider: (accountId?: string) => Promise; - stopIMessageProvider: (accountId?: string) => Promise; - startMSTeamsProvider: () => Promise; - stopMSTeamsProvider: () => Promise; - markWhatsAppLoggedOut: (cleared: boolean, accountId?: string) => void; + startProvider: (provider: ProviderId, accountId?: string) => Promise; + stopProvider: (provider: ProviderId, accountId?: string) => Promise; + markProviderLoggedOut: ( + providerId: ProviderId, + cleared: boolean, + accountId?: string, + ) => void; }; +// Provider docking: lifecycle hooks (`plugin.gateway`) flow through this manager. export function createProviderManager( opts: ProviderManagerOptions, ): ProviderManager { - const { - loadConfig, - logWhatsApp, - logTelegram, - logDiscord, - logSlack, - logSignal, - logIMessage, - logMSTeams, - whatsappRuntimeEnv, - telegramRuntimeEnv, - discordRuntimeEnv, - slackRuntimeEnv, - signalRuntimeEnv, - imessageRuntimeEnv, - msteamsRuntimeEnv, - } = opts; + const { loadConfig, providerLogs, providerRuntimeEnvs } = opts; - const whatsappAborts = new Map(); - const telegramAborts = new Map(); - const discordAborts = new Map(); - const slackAborts = new Map(); - const signalAborts = new Map(); - const imessageAborts = new Map(); - let msteamsAbort: AbortController | null = null; - const whatsappTasks = new Map>(); - let msteamsTask: Promise | null = null; - const telegramTasks = new Map>(); - const discordTasks = new Map>(); - const slackTasks = new Map>(); - const signalTasks = new Map>(); - const imessageTasks = new Map>(); + const providerStores = new Map(); - const whatsappRuntimes = new Map(); - const defaultWhatsAppStatus = (): WebProviderStatus => ({ - running: false, - connected: false, - reconnectAttempts: 0, - lastConnectedAt: null, - lastDisconnect: null, - lastMessageAt: null, - lastEventAt: null, - lastError: null, - }); - const telegramRuntimes = new Map(); - const discordRuntimes = new Map(); - const slackRuntimes = new Map(); - const signalRuntimes = new Map(); - const imessageRuntimes = new Map(); - - const defaultTelegramStatus = (): TelegramRuntimeStatus => ({ - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - mode: null, - }); - const defaultDiscordStatus = (): DiscordRuntimeStatus => ({ - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - bot: undefined, - application: undefined, - }); - const defaultSlackStatus = (): SlackRuntimeStatus => ({ - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }); - const defaultSignalStatus = (): SignalRuntimeStatus => ({ - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - baseUrl: null, - }); - const defaultIMessageStatus = (): IMessageRuntimeStatus => ({ - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - cliPath: null, - dbPath: null, - }); - let msteamsRuntime: MSTeamsRuntimeStatus = { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - port: null, + const getStore = (providerId: ProviderId): ProviderRuntimeStore => { + const existing = providerStores.get(providerId); + if (existing) return existing; + const next = createRuntimeStore(); + providerStores.set(providerId, next); + return next; }; - const updateWhatsAppStatus = (accountId: string, next: WebProviderStatus) => { - whatsappRuntimes.set(accountId, next); - }; - - const startWhatsAppProvider = async (accountId?: string) => { - const cfg = loadConfig(); - const enabledAccounts = listEnabledWhatsAppAccounts(cfg); - const targets = accountId - ? enabledAccounts.filter((a) => a.accountId === accountId) - : enabledAccounts; - if (targets.length === 0) return; - - if (cfg.web?.enabled === false) { - for (const account of targets) { - const current = - whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus(); - whatsappRuntimes.set(account.accountId, { - ...current, - running: false, - connected: false, - lastError: "disabled", - }); - } - logWhatsApp.info("skipping provider start (web.enabled=false)"); - return; - } - - await Promise.all( - targets.map(async (account) => { - if (whatsappTasks.has(account.accountId)) return; - const current = - whatsappRuntimes.get(account.accountId) ?? defaultWhatsAppStatus(); - if (!(await webAuthExists(account.authDir))) { - whatsappRuntimes.set(account.accountId, { - ...current, - running: false, - connected: false, - lastError: "not linked", - }); - logWhatsApp.info( - `[${account.accountId}] skipping provider start (no linked session)`, - ); - return; - } - - const { e164, jid } = readWebSelfId(account.authDir); - const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; - logWhatsApp.info( - `[${account.accountId}] starting provider (${identity})`, - ); - const abort = new AbortController(); - whatsappAborts.set(account.accountId, abort); - whatsappRuntimes.set(account.accountId, { - ...current, - running: true, - connected: false, - lastError: null, - }); - - const task = monitorWebProvider( - shouldLogVerbose(), - undefined, - true, - undefined, - whatsappRuntimeEnv, - abort.signal, - { - statusSink: (next) => updateWhatsAppStatus(account.accountId, next), - accountId: account.accountId, - }, - ) - .catch((err) => { - const latest = - whatsappRuntimes.get(account.accountId) ?? - defaultWhatsAppStatus(); - whatsappRuntimes.set(account.accountId, { - ...latest, - lastError: formatError(err), - }); - logWhatsApp.error( - `[${account.accountId}] provider exited: ${formatError(err)}`, - ); - }) - .finally(() => { - whatsappAborts.delete(account.accountId); - whatsappTasks.delete(account.accountId); - const latest = - whatsappRuntimes.get(account.accountId) ?? - defaultWhatsAppStatus(); - whatsappRuntimes.set(account.accountId, { - ...latest, - running: false, - connected: false, - }); - }); - - whatsappTasks.set(account.accountId, task); - }), + const getRuntime = ( + providerId: ProviderId, + accountId: string, + ): ProviderAccountSnapshot => { + const store = getStore(providerId); + return ( + store.runtimes.get(accountId) ?? + cloneDefaultRuntime(providerId, accountId) ); }; - const stopWhatsAppProvider = async (accountId?: string) => { - const ids = accountId + const setRuntime = ( + providerId: ProviderId, + accountId: string, + patch: ProviderAccountSnapshot, + ): ProviderAccountSnapshot => { + const store = getStore(providerId); + const current = getRuntime(providerId, accountId); + const next = { ...current, ...patch, accountId }; + store.runtimes.set(accountId, next); + return next; + }; + + const startProvider = async (providerId: ProviderId, accountId?: string) => { + const plugin = getProviderPlugin(providerId); + const startAccount = plugin?.gateway?.startAccount; + if (!startAccount) return; + const cfg = loadConfig(); + const store = getStore(providerId); + const accountIds = accountId ? [accountId] - : Array.from( - new Set([...whatsappAborts.keys(), ...whatsappTasks.keys()]), - ); - await Promise.all( - ids.map(async (id) => { - const abort = whatsappAborts.get(id); - const task = whatsappTasks.get(id); - if (!abort && !task) return; - abort?.abort(); - try { - await task; - } catch { - // ignore - } - whatsappAborts.delete(id); - whatsappTasks.delete(id); - const latest = whatsappRuntimes.get(id) ?? defaultWhatsAppStatus(); - whatsappRuntimes.set(id, { - ...latest, - running: false, - connected: false, - }); - }), - ); - }; - - const startTelegramProvider = async (accountId?: string) => { - const cfg = loadConfig(); - const accountIds = accountId ? [accountId] : listTelegramAccountIds(cfg); - if (cfg.telegram?.enabled === false) { - for (const id of accountIds) { - const latest = telegramRuntimes.get(id) ?? defaultTelegramStatus(); - telegramRuntimes.set(id, { - ...latest, - running: false, - lastError: "disabled", - }); - } - if (shouldLogVerbose()) { - logTelegram.debug( - "telegram provider disabled (telegram.enabled=false)", - ); - } - return; - } + : plugin.config.listAccountIds(cfg); + if (accountIds.length === 0) return; await Promise.all( accountIds.map(async (id) => { - const account = resolveTelegramAccount({ cfg, accountId: id }); - if (!account.enabled) { - const latest = - telegramRuntimes.get(account.accountId) ?? defaultTelegramStatus(); - telegramRuntimes.set(account.accountId, { - ...latest, + if (store.tasks.has(id)) return; + const account = plugin.config.resolveAccount(cfg, id); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : isAccountEnabled(account); + if (!enabled) { + setRuntime(providerId, id, { + accountId: id, running: false, - lastError: "disabled", + lastError: + plugin.config.disabledReason?.(account, cfg) ?? "disabled", }); return; } - if (telegramTasks.has(account.accountId)) return; - const token = account.token.trim(); - if (!token) { - const latest = - telegramRuntimes.get(account.accountId) ?? defaultTelegramStatus(); - telegramRuntimes.set(account.accountId, { - ...latest, + + let configured = true; + if (plugin.config.isConfigured) { + configured = await plugin.config.isConfigured(account, cfg); + } + if (!configured) { + setRuntime(providerId, id, { + accountId: id, running: false, - lastError: "not configured", + lastError: + plugin.config.unconfiguredReason?.(account, cfg) ?? + "not configured", }); - if (shouldLogVerbose()) { - logTelegram.debug( - `[${account.accountId}] telegram provider not configured (no TELEGRAM_BOT_TOKEN)`, - ); - } return; } - let telegramBotLabel = ""; - try { - const probe = await probeTelegram(token, 2500, account.config.proxy); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) telegramBotLabel = ` (@${username})`; - } catch (err) { - if (shouldLogVerbose()) { - logTelegram.debug( - `[${account.accountId}] bot probe failed: ${String(err)}`, - ); - } - } - - logTelegram.info( - `[${account.accountId}] starting provider${telegramBotLabel}`, - ); const abort = new AbortController(); - telegramAborts.set(account.accountId, abort); - const latest = - telegramRuntimes.get(account.accountId) ?? defaultTelegramStatus(); - telegramRuntimes.set(account.accountId, { - ...latest, - running: true, - lastStartAt: Date.now(), - lastError: null, - mode: account.config.webhookUrl ? "webhook" : "polling", - }); - const task = monitorTelegramProvider({ - token, - accountId: account.accountId, - config: cfg, - runtime: telegramRuntimeEnv, - abortSignal: abort.signal, - useWebhook: Boolean(account.config.webhookUrl), - webhookUrl: account.config.webhookUrl, - webhookSecret: account.config.webhookSecret, - webhookPath: account.config.webhookPath, - }) - .catch((err) => { - const current = - telegramRuntimes.get(account.accountId) ?? - defaultTelegramStatus(); - telegramRuntimes.set(account.accountId, { - ...current, - lastError: formatError(err), - }); - logTelegram.error( - `[${account.accountId}] provider exited: ${formatError(err)}`, - ); - }) - .finally(() => { - telegramAborts.delete(account.accountId); - telegramTasks.delete(account.accountId); - const current = - telegramRuntimes.get(account.accountId) ?? - defaultTelegramStatus(); - telegramRuntimes.set(account.accountId, { - ...current, - running: false, - lastStopAt: Date.now(), - }); - }); - telegramTasks.set(account.accountId, task); - }), - ); - }; - - const stopTelegramProvider = async (accountId?: string) => { - const ids = accountId - ? [accountId] - : Array.from( - new Set([...telegramAborts.keys(), ...telegramTasks.keys()]), - ); - await Promise.all( - ids.map(async (id) => { - const abort = telegramAborts.get(id); - const task = telegramTasks.get(id); - if (!abort && !task) return; - abort?.abort(); - try { - await task; - } catch { - // ignore - } - telegramAborts.delete(id); - telegramTasks.delete(id); - const latest = telegramRuntimes.get(id) ?? defaultTelegramStatus(); - telegramRuntimes.set(id, { - ...latest, - running: false, - lastStopAt: Date.now(), - }); - }), - ); - }; - - const startDiscordProvider = async (accountId?: string) => { - const cfg = loadConfig(); - const accountIds = accountId ? [accountId] : listDiscordAccountIds(cfg); - if (cfg.discord?.enabled === false) { - for (const id of accountIds) { - const latest = discordRuntimes.get(id) ?? defaultDiscordStatus(); - discordRuntimes.set(id, { - ...latest, - running: false, - lastError: "disabled", - }); - } - if (shouldLogVerbose()) { - logDiscord.debug("discord provider disabled (discord.enabled=false)"); - } - return; - } - - await Promise.all( - accountIds.map(async (id) => { - const account = resolveDiscordAccount({ cfg, accountId: id }); - if (!account.enabled) { - const latest = - discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); - discordRuntimes.set(account.accountId, { - ...latest, - running: false, - lastError: "disabled", - }); - return; - } - if (discordTasks.has(account.accountId)) return; - const token = account.token.trim(); - if (!token) { - const latest = - discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); - discordRuntimes.set(account.accountId, { - ...latest, - running: false, - lastError: "not configured", - }); - if (shouldLogVerbose()) { - logDiscord.debug( - `[${account.accountId}] discord provider not configured (no DISCORD_BOT_TOKEN)`, - ); - } - return; - } - let discordBotLabel = ""; - try { - const probe = await probeDiscord(token, 2500, { - includeApplication: true, - }); - const username = probe.ok ? probe.bot?.username?.trim() : null; - if (username) discordBotLabel = ` (@${username})`; - const latest = - discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); - discordRuntimes.set(account.accountId, { - ...latest, - bot: probe.bot, - application: probe.application, - }); - const messageContent = probe.application?.intents?.messageContent; - if (messageContent === "disabled") { - logDiscord.warn( - `[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`, - ); - } else if (messageContent === "limited") { - logDiscord.info( - `[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`, - ); - } - } catch (err) { - if (shouldLogVerbose()) { - logDiscord.debug( - `[${account.accountId}] bot probe failed: ${String(err)}`, - ); - } - } - logDiscord.info( - `[${account.accountId}] starting provider${discordBotLabel}`, - ); - const abort = new AbortController(); - discordAborts.set(account.accountId, abort); - const latest = - discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); - discordRuntimes.set(account.accountId, { - ...latest, + store.aborts.set(id, abort); + setRuntime(providerId, id, { + accountId: id, running: true, lastStartAt: Date.now(), lastError: null, }); - const task = monitorDiscordProvider({ - token, - accountId: account.accountId, - config: cfg, - runtime: discordRuntimeEnv, + + const log = providerLogs[providerId]; + const task = startAccount({ + cfg, + accountId: id, + account, + runtime: providerRuntimeEnvs[providerId], abortSignal: abort.signal, - mediaMaxMb: account.config.mediaMaxMb, - historyLimit: account.config.historyLimit, - }) + log, + getStatus: () => getRuntime(providerId, id), + setStatus: (next) => setRuntime(providerId, id, next), + }); + const tracked = Promise.resolve(task) .catch((err) => { - const current = - discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); - discordRuntimes.set(account.accountId, { - ...current, - lastError: formatError(err), - }); - logDiscord.error( - `[${account.accountId}] provider exited: ${formatError(err)}`, - ); + const message = formatErrorMessage(err); + setRuntime(providerId, id, { accountId: id, lastError: message }); + log.error?.(`[${id}] provider exited: ${message}`); }) .finally(() => { - discordAborts.delete(account.accountId); - discordTasks.delete(account.accountId); - const current = - discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); - discordRuntimes.set(account.accountId, { - ...current, + store.aborts.delete(id); + store.tasks.delete(id); + setRuntime(providerId, id, { + accountId: id, running: false, lastStopAt: Date.now(), }); }); - discordTasks.set(account.accountId, task); + store.tasks.set(id, tracked); }), ); }; - const stopDiscordProvider = async (accountId?: string) => { - const ids = accountId - ? [accountId] - : Array.from(new Set([...discordAborts.keys(), ...discordTasks.keys()])); + const stopProvider = async (providerId: ProviderId, accountId?: string) => { + const plugin = getProviderPlugin(providerId); + const cfg = loadConfig(); + const store = getStore(providerId); + const knownIds = new Set([ + ...store.aborts.keys(), + ...store.tasks.keys(), + ...(plugin ? plugin.config.listAccountIds(cfg) : []), + ]); + if (accountId) { + knownIds.clear(); + knownIds.add(accountId); + } + await Promise.all( - ids.map(async (id) => { - const abort = discordAborts.get(id); - const task = discordTasks.get(id); - if (!abort && !task) return; + Array.from(knownIds.values()).map(async (id) => { + const abort = store.aborts.get(id); + const task = store.tasks.get(id); + if (!abort && !task && !plugin?.gateway?.stopAccount) return; abort?.abort(); + if (plugin?.gateway?.stopAccount) { + const account = plugin.config.resolveAccount(cfg, id); + await plugin.gateway.stopAccount({ + cfg, + accountId: id, + account, + runtime: providerRuntimeEnvs[providerId], + abortSignal: abort?.signal ?? new AbortController().signal, + log: providerLogs[providerId], + getStatus: () => getRuntime(providerId, id), + setStatus: (next) => setRuntime(providerId, id, next), + }); + } try { await task; } catch { // ignore } - discordAborts.delete(id); - discordTasks.delete(id); - const latest = discordRuntimes.get(id) ?? defaultDiscordStatus(); - discordRuntimes.set(id, { - ...latest, + store.aborts.delete(id); + store.tasks.delete(id); + setRuntime(providerId, id, { + accountId: id, running: false, lastStopAt: Date.now(), }); @@ -681,628 +243,87 @@ export function createProviderManager( ); }; - const startSlackProvider = async (accountId?: string) => { - const cfg = loadConfig(); - const accountIds = accountId ? [accountId] : listSlackAccountIds(cfg); - if (cfg.slack?.enabled === false) { - for (const id of accountIds) { - const latest = slackRuntimes.get(id) ?? defaultSlackStatus(); - slackRuntimes.set(id, { - ...latest, - running: false, - lastError: "disabled", - }); - } - if (shouldLogVerbose()) { - logSlack.debug("slack provider disabled (slack.enabled=false)"); - } - return; - } - - await Promise.all( - accountIds.map(async (id) => { - const account = resolveSlackAccount({ cfg, accountId: id }); - if (!account.enabled) { - const latest = - slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); - slackRuntimes.set(account.accountId, { - ...latest, - running: false, - lastError: "disabled", - }); - return; - } - if (slackTasks.has(account.accountId)) return; - const botToken = account.botToken?.trim(); - const appToken = account.appToken?.trim(); - if (!botToken || !appToken) { - const latest = - slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); - slackRuntimes.set(account.accountId, { - ...latest, - running: false, - lastError: "not configured", - }); - if (shouldLogVerbose()) { - logSlack.debug( - `[${account.accountId}] slack provider not configured (missing SLACK_BOT_TOKEN/SLACK_APP_TOKEN)`, - ); - } - return; - } - logSlack.info(`[${account.accountId}] starting provider`); - const abort = new AbortController(); - slackAborts.set(account.accountId, abort); - const latest = - slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); - slackRuntimes.set(account.accountId, { - ...latest, - running: true, - lastStartAt: Date.now(), - lastError: null, - }); - const task = monitorSlackProvider({ - botToken, - appToken, - accountId: account.accountId, - config: cfg, - runtime: slackRuntimeEnv, - abortSignal: abort.signal, - mediaMaxMb: account.config.mediaMaxMb, - slashCommand: account.config.slashCommand, - }) - .catch((err) => { - const current = - slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); - slackRuntimes.set(account.accountId, { - ...current, - lastError: formatError(err), - }); - logSlack.error( - `[${account.accountId}] provider exited: ${formatError(err)}`, - ); - }) - .finally(() => { - slackAborts.delete(account.accountId); - slackTasks.delete(account.accountId); - const current = - slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); - slackRuntimes.set(account.accountId, { - ...current, - running: false, - lastStopAt: Date.now(), - }); - }); - slackTasks.set(account.accountId, task); - }), - ); - }; - - const stopSlackProvider = async (accountId?: string) => { - const ids = accountId - ? [accountId] - : Array.from(new Set([...slackAborts.keys(), ...slackTasks.keys()])); - await Promise.all( - ids.map(async (id) => { - const abort = slackAborts.get(id); - const task = slackTasks.get(id); - if (!abort && !task) return; - abort?.abort(); - try { - await task; - } catch { - // ignore - } - slackAborts.delete(id); - slackTasks.delete(id); - const latest = slackRuntimes.get(id) ?? defaultSlackStatus(); - slackRuntimes.set(id, { - ...latest, - running: false, - lastStopAt: Date.now(), - }); - }), - ); - }; - - const startSignalProvider = async (accountId?: string) => { - const cfg = loadConfig(); - const accountIds = accountId ? [accountId] : listSignalAccountIds(cfg); - if (!cfg.signal) { - for (const id of accountIds) { - const latest = signalRuntimes.get(id) ?? defaultSignalStatus(); - signalRuntimes.set(id, { - ...latest, - running: false, - lastError: "not configured", - }); - } - if (shouldLogVerbose()) { - logSignal.debug("signal provider not configured (no signal config)"); - } - return; - } - - await Promise.all( - accountIds.map(async (id) => { - const account = resolveSignalAccount({ cfg, accountId: id }); - if (!account.enabled) { - const latest = - signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); - signalRuntimes.set(account.accountId, { - ...latest, - running: false, - lastError: "disabled", - baseUrl: account.baseUrl, - }); - return; - } - if (!account.configured) { - const latest = - signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); - signalRuntimes.set(account.accountId, { - ...latest, - running: false, - lastError: "not configured", - baseUrl: account.baseUrl, - }); - if (shouldLogVerbose()) { - logSignal.debug( - `[${account.accountId}] signal provider not configured (missing signal config)`, - ); - } - return; - } - if (signalTasks.has(account.accountId)) return; - logSignal.info( - `[${account.accountId}] starting provider (${account.baseUrl})`, - ); - const abort = new AbortController(); - signalAborts.set(account.accountId, abort); - const latest = - signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); - signalRuntimes.set(account.accountId, { - ...latest, - running: true, - lastStartAt: Date.now(), - lastError: null, - baseUrl: account.baseUrl, - }); - const task = monitorSignalProvider({ - accountId: account.accountId, - config: cfg, - runtime: signalRuntimeEnv, - abortSignal: abort.signal, - mediaMaxMb: account.config.mediaMaxMb, - }) - .catch((err) => { - const current = - signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); - signalRuntimes.set(account.accountId, { - ...current, - lastError: formatError(err), - }); - logSignal.error( - `[${account.accountId}] provider exited: ${formatError(err)}`, - ); - }) - .finally(() => { - signalAborts.delete(account.accountId); - signalTasks.delete(account.accountId); - const current = - signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); - signalRuntimes.set(account.accountId, { - ...current, - running: false, - lastStopAt: Date.now(), - }); - }); - signalTasks.set(account.accountId, task); - }), - ); - }; - - const stopSignalProvider = async (accountId?: string) => { - const ids = accountId - ? [accountId] - : Array.from(new Set([...signalAborts.keys(), ...signalTasks.keys()])); - await Promise.all( - ids.map(async (id) => { - const abort = signalAborts.get(id); - const task = signalTasks.get(id); - if (!abort && !task) return; - abort?.abort(); - try { - await task; - } catch { - // ignore - } - signalAborts.delete(id); - signalTasks.delete(id); - const latest = signalRuntimes.get(id) ?? defaultSignalStatus(); - signalRuntimes.set(id, { - ...latest, - running: false, - lastStopAt: Date.now(), - }); - }), - ); - }; - - const startIMessageProvider = async (accountId?: string) => { - const cfg = loadConfig(); - const accountIds = accountId ? [accountId] : listIMessageAccountIds(cfg); - if (!cfg.imessage) { - for (const id of accountIds) { - const latest = imessageRuntimes.get(id) ?? defaultIMessageStatus(); - imessageRuntimes.set(id, { - ...latest, - running: false, - lastError: "not configured", - }); - } - // keep quiet by default; this is a normal state - if (shouldLogVerbose()) { - logIMessage.debug( - "imessage provider not configured (no imessage config)", - ); - } - return; - } - - await Promise.all( - accountIds.map(async (id) => { - const account = resolveIMessageAccount({ cfg, accountId: id }); - if (!account.enabled) { - const latest = - imessageRuntimes.get(account.accountId) ?? defaultIMessageStatus(); - imessageRuntimes.set(account.accountId, { - ...latest, - running: false, - lastError: "disabled", - }); - if (shouldLogVerbose()) { - logIMessage.debug( - `[${account.accountId}] imessage provider disabled (imessage.enabled=false)`, - ); - } - return; - } - if (imessageTasks.has(account.accountId)) return; - const cliPath = account.config.cliPath?.trim() || "imsg"; - const dbPath = account.config.dbPath?.trim(); - logIMessage.info( - `[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, - ); - const abort = new AbortController(); - imessageAborts.set(account.accountId, abort); - const latest = - imessageRuntimes.get(account.accountId) ?? defaultIMessageStatus(); - imessageRuntimes.set(account.accountId, { - ...latest, - running: true, - lastStartAt: Date.now(), - lastError: null, - cliPath, - dbPath: dbPath ?? null, - }); - const task = monitorIMessageProvider({ - accountId: account.accountId, - config: cfg, - runtime: imessageRuntimeEnv, - abortSignal: abort.signal, - }) - .catch((err) => { - const current = - imessageRuntimes.get(account.accountId) ?? - defaultIMessageStatus(); - imessageRuntimes.set(account.accountId, { - ...current, - lastError: formatError(err), - }); - logIMessage.error( - `[${account.accountId}] provider exited: ${formatError(err)}`, - ); - }) - .finally(() => { - imessageAborts.delete(account.accountId); - imessageTasks.delete(account.accountId); - const current = - imessageRuntimes.get(account.accountId) ?? - defaultIMessageStatus(); - imessageRuntimes.set(account.accountId, { - ...current, - running: false, - lastStopAt: Date.now(), - }); - }); - imessageTasks.set(account.accountId, task); - }), - ); - }; - - const stopIMessageProvider = async (accountId?: string) => { - const ids = accountId - ? [accountId] - : Array.from( - new Set([...imessageAborts.keys(), ...imessageTasks.keys()]), - ); - await Promise.all( - ids.map(async (id) => { - const abort = imessageAborts.get(id); - const task = imessageTasks.get(id); - if (!abort && !task) return; - abort?.abort(); - try { - await task; - } catch { - // ignore - } - imessageAborts.delete(id); - imessageTasks.delete(id); - const latest = imessageRuntimes.get(id) ?? defaultIMessageStatus(); - imessageRuntimes.set(id, { - ...latest, - running: false, - lastStopAt: Date.now(), - }); - }), - ); - }; - - const startMSTeamsProvider = async () => { - if (msteamsTask) return; - const cfg = loadConfig(); - if (!cfg.msteams) { - msteamsRuntime = { - ...msteamsRuntime, - running: false, - lastError: "not configured", - }; - if (shouldLogVerbose()) { - logMSTeams.debug("msteams provider not configured (no msteams config)"); - } - return; - } - if (cfg.msteams?.enabled === false) { - msteamsRuntime = { - ...msteamsRuntime, - running: false, - lastError: "disabled", - }; - if (shouldLogVerbose()) { - logMSTeams.debug("msteams provider disabled (msteams.enabled=false)"); - } - return; - } - const { monitorMSTeamsProvider } = await import("../msteams/index.js"); - const port = cfg.msteams?.webhook?.port ?? 3978; - logMSTeams.info(`starting provider (port ${port})`); - msteamsAbort = new AbortController(); - msteamsRuntime = { - ...msteamsRuntime, - running: true, - lastStartAt: Date.now(), - lastError: null, - port, - }; - const task = monitorMSTeamsProvider({ - cfg, - runtime: msteamsRuntimeEnv, - abortSignal: msteamsAbort.signal, - }) - .catch((err) => { - msteamsRuntime = { - ...msteamsRuntime, - lastError: formatError(err), - }; - logMSTeams.error(`provider exited: ${formatError(err)}`); - }) - .finally(() => { - msteamsAbort = null; - msteamsTask = null; - msteamsRuntime = { - ...msteamsRuntime, - running: false, - lastStopAt: Date.now(), - }; - }); - msteamsTask = task; - }; - - const stopMSTeamsProvider = async () => { - if (!msteamsAbort && !msteamsTask) return; - msteamsAbort?.abort(); - try { - await msteamsTask; - } catch { - // ignore - } - msteamsAbort = null; - msteamsTask = null; - msteamsRuntime = { - ...msteamsRuntime, - running: false, - lastStopAt: Date.now(), - }; - }; - const startProviders = async () => { - await startWhatsAppProvider(); - await startDiscordProvider(); - await startSlackProvider(); - await startTelegramProvider(); - await startSignalProvider(); - await startIMessageProvider(); - await startMSTeamsProvider(); + for (const plugin of listProviderPlugins()) { + await startProvider(plugin.id); + } }; - const markWhatsAppLoggedOut = (cleared: boolean, accountId?: string) => { + const markProviderLoggedOut = ( + providerId: ProviderId, + cleared: boolean, + accountId?: string, + ) => { + const plugin = getProviderPlugin(providerId); + if (!plugin) return; const cfg = loadConfig(); - const resolvedId = accountId ?? resolveDefaultWhatsAppAccountId(cfg); - const current = whatsappRuntimes.get(resolvedId) ?? defaultWhatsAppStatus(); - whatsappRuntimes.set(resolvedId, { - ...current, + const resolvedId = + accountId ?? + resolveProviderDefaultAccountId({ + plugin, + cfg, + }); + const current = getRuntime(providerId, resolvedId); + const next: ProviderAccountSnapshot = { + accountId: resolvedId, running: false, - connected: false, lastError: cleared ? "logged out" : current.lastError, - }); + }; + if (typeof current.connected === "boolean") { + next.connected = false; + } + setRuntime(providerId, resolvedId, next); }; const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => { const cfg = loadConfig(); - const defaultWhatsAppId = resolveDefaultWhatsAppAccountId(cfg); - const whatsapp = - whatsappRuntimes.get(defaultWhatsAppId) ?? defaultWhatsAppStatus(); - const whatsappAccounts = Object.fromEntries( - Array.from(whatsappRuntimes.entries()).map(([id, status]) => [ - id, - { ...status }, - ]), - ); - - const telegramAccounts = Object.fromEntries( - listTelegramAccountIds(cfg).map((id) => { - const account = resolveTelegramAccount({ cfg, accountId: id }); + const providers: ProviderRuntimeSnapshot["providers"] = {}; + const providerAccounts: ProviderRuntimeSnapshot["providerAccounts"] = {}; + for (const plugin of listProviderPlugins()) { + const store = getStore(plugin.id); + const accountIds = plugin.config.listAccountIds(cfg); + const defaultAccountId = resolveProviderDefaultAccountId({ + plugin, + cfg, + accountIds, + }); + const accounts: Record = {}; + for (const id of accountIds) { + const account = plugin.config.resolveAccount(cfg, id); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : isAccountEnabled(account); + const described = plugin.config.describeAccount?.(account, cfg); + const configured = described?.configured; const current = - telegramRuntimes.get(account.accountId) ?? defaultTelegramStatus(); - const status: TelegramRuntimeStatus = { - ...current, - mode: - current.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), - }; - if (!status.running) { - if (!account.enabled) { - status.lastError ??= "disabled"; - } else if (!account.token) { - status.lastError ??= "not configured"; + store.runtimes.get(id) ?? cloneDefaultRuntime(plugin.id, id); + const next = { ...current, accountId: id }; + if (!next.running) { + if (!enabled) { + next.lastError ??= + plugin.config.disabledReason?.(account, cfg) ?? "disabled"; + } else if (configured === false) { + next.lastError ??= + plugin.config.unconfiguredReason?.(account, cfg) ?? + "not configured"; } } - return [account.accountId, status]; - }), - ); - const telegramDefaultId = resolveDefaultTelegramAccountId(cfg); - const telegram = - telegramAccounts[telegramDefaultId] ?? defaultTelegramStatus(); - - const discordAccounts = Object.fromEntries( - listDiscordAccountIds(cfg).map((id) => { - const account = resolveDiscordAccount({ cfg, accountId: id }); - const current = - discordRuntimes.get(account.accountId) ?? defaultDiscordStatus(); - const status: DiscordRuntimeStatus = { ...current }; - if (!status.running) { - if (!account.enabled) { - status.lastError ??= "disabled"; - } else if (!account.token) { - status.lastError ??= "not configured"; - } - } - return [account.accountId, status]; - }), - ); - const discordDefaultId = resolveDefaultDiscordAccountId(cfg); - const discord = discordAccounts[discordDefaultId] ?? defaultDiscordStatus(); - - const slackAccounts = Object.fromEntries( - listSlackAccountIds(cfg).map((id) => { - const account = resolveSlackAccount({ cfg, accountId: id }); - const current = - slackRuntimes.get(account.accountId) ?? defaultSlackStatus(); - const status: SlackRuntimeStatus = { ...current }; - if (!status.running) { - if (!account.enabled) { - status.lastError ??= "disabled"; - } else if (!account.botToken || !account.appToken) { - status.lastError ??= "not configured"; - } - } - return [account.accountId, status]; - }), - ); - const slackDefaultId = resolveDefaultSlackAccountId(cfg); - const slack = slackAccounts[slackDefaultId] ?? defaultSlackStatus(); - - const signalAccounts = Object.fromEntries( - listSignalAccountIds(cfg).map((id) => { - const account = resolveSignalAccount({ cfg, accountId: id }); - const current = - signalRuntimes.get(account.accountId) ?? defaultSignalStatus(); - const status: SignalRuntimeStatus = { - ...current, - baseUrl: current.baseUrl ?? account.baseUrl, - }; - if (!status.running) { - if (!account.enabled) { - status.lastError ??= "disabled"; - } else if (!account.configured) { - status.lastError ??= "not configured"; - } - } - return [account.accountId, status]; - }), - ); - const signalDefaultId = resolveDefaultSignalAccountId(cfg); - const signal = signalAccounts[signalDefaultId] ?? defaultSignalStatus(); - - const imessageAccounts = Object.fromEntries( - listIMessageAccountIds(cfg).map((id) => { - const account = resolveIMessageAccount({ cfg, accountId: id }); - const current = - imessageRuntimes.get(account.accountId) ?? defaultIMessageStatus(); - const cliPath = account.config.cliPath?.trim() || "imsg"; - const dbPath = account.config.dbPath?.trim() || null; - const status: IMessageRuntimeStatus = { - ...current, - cliPath: current.cliPath ?? cliPath, - dbPath: current.dbPath ?? dbPath, - }; - if (!status.running && !account.enabled) { - status.lastError ??= "disabled"; - } - if (!status.running && !cfg.imessage) { - status.lastError ??= "not configured"; - } - return [account.accountId, status]; - }), - ); - const imessageDefaultId = resolveDefaultIMessageAccountId(cfg); - const imessage = - imessageAccounts[imessageDefaultId] ?? defaultIMessageStatus(); - return { - whatsapp: { ...whatsapp }, - whatsappAccounts, - telegram, - telegramAccounts, - discord, - discordAccounts, - slack, - slackAccounts, - signal, - signalAccounts, - imessage, - imessageAccounts, - msteams: { ...msteamsRuntime }, - }; + accounts[id] = next; + } + const defaultAccount = + accounts[defaultAccountId] ?? + cloneDefaultRuntime(plugin.id, defaultAccountId); + providers[plugin.id] = defaultAccount; + providerAccounts[plugin.id] = accounts; + } + return { providers, providerAccounts }; }; return { getRuntimeSnapshot, startProviders, - startWhatsAppProvider, - stopWhatsAppProvider, - startTelegramProvider, - stopTelegramProvider, - startDiscordProvider, - stopDiscordProvider, - startSlackProvider, - stopSlackProvider, - startSignalProvider, - stopSignalProvider, - startIMessageProvider, - stopIMessageProvider, - startMSTeamsProvider, - stopMSTeamsProvider, - markWhatsAppLoggedOut, + startProvider, + stopProvider, + markProviderLoggedOut, }; } diff --git a/src/gateway/server.agent.test.ts b/src/gateway/server.agent.test.ts index 682f76969..764291f74 100644 --- a/src/gateway/server.agent.test.ts +++ b/src/gateway/server.agent.test.ts @@ -7,6 +7,10 @@ import { emitAgentEvent, registerAgentRunContext, } from "../infra/agent-events.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { agentCommand, connectOk, @@ -30,7 +34,7 @@ function expectProviders(call: Record, provider: string) { } describe("gateway server agent", () => { - test("agent falls back to allowFrom when lastTo is stale", async () => { + test("agent marks implicit delivery when lastTo is stale", async () => { testState.allowFrom = ["+436769770569"]; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); @@ -66,7 +70,8 @@ describe("gateway server agent", () => { const spy = vi.mocked(agentCommand); const call = spy.mock.calls.at(-1)?.[0] as Record; expectProviders(call, "whatsapp"); - expect(call.to).toBe("+436769770569"); + expect(call.to).toBe("+1555"); + expect(call.deliveryTargetMode).toBe("implicit"); expect(call.sessionId).toBe("sess-main-stale"); ws.close(); @@ -785,10 +790,10 @@ describe("gateway server agent", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws, { client: { - name: "webchat", + id: GATEWAY_CLIENT_NAMES.WEBCHAT, version: "1.0.0", platform: "test", - mode: "webchat", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); @@ -833,10 +838,10 @@ describe("gateway server agent", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws, { client: { - name: "webchat", + id: GATEWAY_CLIENT_NAMES.WEBCHAT, version: "1.0.0", platform: "test", - mode: "webchat", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); @@ -893,10 +898,10 @@ describe("gateway server agent", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws, { client: { - name: "webchat", + id: GATEWAY_CLIENT_NAMES.WEBCHAT, version: "1.0.0", platform: "test", - mode: "webchat", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts index a299efb1f..2bbf86e7d 100644 --- a/src/gateway/server.chat.test.ts +++ b/src/gateway/server.chat.test.ts @@ -3,6 +3,10 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { agentCommand, connectOk, @@ -31,10 +35,10 @@ describe("gateway server chat", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws, { client: { - name: "clawdbot-control-ui", + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, version: "dev", platform: "web", - mode: "webchat", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index 9c9b869bd..2dbb16a99 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -3,6 +3,10 @@ import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; import { emitAgentEvent } from "../infra/agent-events.js"; import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { connectOk, getFreePort, @@ -238,12 +242,12 @@ describe("gateway server health/presence", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws, { client: { - name: "fingerprint", + id: GATEWAY_CLIENT_NAMES.FINGERPRINT, version: "9.9.9", platform: "test", deviceFamily: "iPad", modelIdentifier: "iPad16,6", - mode: "ui", + mode: GATEWAY_CLIENT_MODES.UI, instanceId: "abc", }, }); @@ -264,7 +268,7 @@ describe("gateway server health/presence", () => { const presenceRes = await presenceP; const entries = presenceRes.payload as Array>; const clientEntry = entries.find((e) => e.instanceId === "abc"); - expect(clientEntry?.host).toBe("fingerprint"); + expect(clientEntry?.host).toBe(GATEWAY_CLIENT_NAMES.FINGERPRINT); expect(clientEntry?.version).toBe("9.9.9"); expect(clientEntry?.mode).toBe("ui"); expect(clientEntry?.deviceFamily).toBe("iPad"); @@ -279,10 +283,10 @@ describe("gateway server health/presence", () => { const cliId = `cli-${randomUUID()}`; await connectOk(ws, { client: { - name: "cli", + id: GATEWAY_CLIENT_NAMES.CLI, version: "dev", platform: "test", - mode: "cli", + mode: GATEWAY_CLIENT_MODES.CLI, instanceId: cliId, }, }); diff --git a/src/gateway/server.node-bridge.test.ts b/src/gateway/server.node-bridge.test.ts index de24ad840..d9ef6de77 100644 --- a/src/gateway/server.node-bridge.test.ts +++ b/src/gateway/server.node-bridge.test.ts @@ -3,6 +3,10 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { agentCommand, bridgeInvoke, @@ -870,10 +874,10 @@ describe("gateway server node/bridge", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws, { client: { - name: "webchat", + id: GATEWAY_CLIENT_NAMES.WEBCHAT, version: "1.0.0", platform: "test", - mode: "webchat", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, }, }); diff --git a/src/gateway/server.providers.test.ts b/src/gateway/server.providers.test.ts index c91fe4499..60f53e3c0 100644 --- a/src/gateway/server.providers.test.ts +++ b/src/gateway/server.providers.test.ts @@ -18,28 +18,28 @@ describe("gateway server providers", () => { await connectOk(ws); const res = await rpcReq<{ - whatsapp?: { linked?: boolean }; - telegram?: { - configured?: boolean; - tokenSource?: string; - probe?: unknown; - lastProbeAt?: unknown; - }; - signal?: { - configured?: boolean; - probe?: unknown; - lastProbeAt?: unknown; - }; + providers?: Record< + string, + | { + configured?: boolean; + tokenSource?: string; + probe?: unknown; + lastProbeAt?: unknown; + } + | { linked?: boolean } + >; }>(ws, "providers.status", { probe: false, timeoutMs: 2000 }); expect(res.ok).toBe(true); - expect(res.payload?.whatsapp).toBeTruthy(); - expect(res.payload?.telegram?.configured).toBe(false); - expect(res.payload?.telegram?.tokenSource).toBe("none"); - expect(res.payload?.telegram?.probe).toBeUndefined(); - expect(res.payload?.telegram?.lastProbeAt).toBeNull(); - expect(res.payload?.signal?.configured).toBe(false); - expect(res.payload?.signal?.probe).toBeUndefined(); - expect(res.payload?.signal?.lastProbeAt).toBeNull(); + const telegram = res.payload?.providers?.telegram; + const signal = res.payload?.providers?.signal; + expect(res.payload?.providers?.whatsapp).toBeTruthy(); + expect(telegram?.configured).toBe(false); + expect(telegram?.tokenSource).toBe("none"); + expect(telegram?.probe).toBeUndefined(); + expect(telegram?.lastProbeAt).toBeNull(); + expect(signal?.configured).toBe(false); + expect(signal?.probe).toBeUndefined(); + expect(signal?.lastProbeAt).toBeNull(); ws.close(); await server.close(); @@ -50,19 +50,24 @@ describe("gateway server providers", () => { } }); - test("web.logout reports no session when missing", async () => { + test("providers.logout reports no session when missing", async () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); - const res = await rpcReq<{ cleared?: boolean }>(ws, "web.logout"); + const res = await rpcReq<{ cleared?: boolean; provider?: string }>( + ws, + "providers.logout", + { provider: "whatsapp" }, + ); expect(res.ok).toBe(true); + expect(res.payload?.provider).toBe("whatsapp"); expect(res.payload?.cleared).toBe(false); ws.close(); await server.close(); }); - test("telegram.logout clears bot token from config", async () => { + test("providers.logout clears telegram bot token from config", async () => { const prevToken = process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN; const { readConfigFileSnapshot, writeConfigFile } = @@ -77,11 +82,13 @@ describe("gateway server providers", () => { const { server, ws } = await startServerWithClient(); await connectOk(ws); - const res = await rpcReq<{ cleared?: boolean; envToken?: boolean }>( - ws, - "telegram.logout", - ); + const res = await rpcReq<{ + cleared?: boolean; + envToken?: boolean; + provider?: string; + }>(ws, "providers.logout", { provider: "telegram" }); expect(res.ok).toBe(true); + expect(res.payload?.provider).toBe("telegram"); expect(res.payload?.cleared).toBe(true); expect(res.payload?.envToken).toBe(false); diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index 3172208d3..18c7b81a6 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -32,79 +32,72 @@ const hoisted = vi.hoisted(() => { const providerManager = { getRuntimeSnapshot: vi.fn(() => ({ - whatsapp: { - running: false, - connected: false, - reconnectAttempts: 0, - lastConnectedAt: null, - lastDisconnect: null, - lastMessageAt: null, - lastEventAt: null, - lastError: null, + providers: { + whatsapp: { + running: false, + connected: false, + reconnectAttempts: 0, + lastConnectedAt: null, + lastDisconnect: null, + lastMessageAt: null, + lastEventAt: null, + lastError: null, + }, + telegram: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + mode: null, + }, + discord: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + slack: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + signal: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + baseUrl: null, + }, + imessage: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + cliPath: null, + dbPath: null, + }, + msteams: { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, }, - whatsappAccounts: {}, - telegram: { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - mode: null, - }, - telegramAccounts: {}, - discord: { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - discordAccounts: {}, - slack: { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - slackAccounts: {}, - signal: { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - baseUrl: null, - }, - signalAccounts: {}, - imessage: { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - cliPath: null, - dbPath: null, - }, - imessageAccounts: {}, - msteams: { - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, + providerAccounts: { + whatsapp: {}, + telegram: {}, + discord: {}, + slack: {}, + signal: {}, + imessage: {}, + msteams: {}, }, })), startProviders: vi.fn(async () => {}), - startWhatsAppProvider: vi.fn(async () => {}), - stopWhatsAppProvider: vi.fn(async () => {}), - startTelegramProvider: vi.fn(async () => {}), - stopTelegramProvider: vi.fn(async () => {}), - startDiscordProvider: vi.fn(async () => {}), - stopDiscordProvider: vi.fn(async () => {}), - startSlackProvider: vi.fn(async () => {}), - stopSlackProvider: vi.fn(async () => {}), - startSignalProvider: vi.fn(async () => {}), - stopSignalProvider: vi.fn(async () => {}), - startIMessageProvider: vi.fn(async () => {}), - stopIMessageProvider: vi.fn(async () => {}), - startMSTeamsProvider: vi.fn(async () => {}), - stopMSTeamsProvider: vi.fn(async () => {}), - markWhatsAppLoggedOut: vi.fn(), + startProvider: vi.fn(async () => {}), + stopProvider: vi.fn(async () => {}), + markProviderLoggedOut: vi.fn(), }; const createProviderManager = vi.fn(() => providerManager); @@ -265,33 +258,35 @@ describe("gateway hot reload", () => { expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1); expect(hoisted.cronInstances[1].start).toHaveBeenCalledTimes(1); - expect(hoisted.providerManager.stopWhatsAppProvider).toHaveBeenCalledTimes( - 1, + expect(hoisted.providerManager.stopProvider).toHaveBeenCalledTimes(5); + expect(hoisted.providerManager.startProvider).toHaveBeenCalledTimes(5); + expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith( + "whatsapp", ); - expect(hoisted.providerManager.startWhatsAppProvider).toHaveBeenCalledTimes( - 1, + expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith( + "whatsapp", ); - expect(hoisted.providerManager.stopTelegramProvider).toHaveBeenCalledTimes( - 1, + expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith( + "telegram", ); - expect(hoisted.providerManager.startTelegramProvider).toHaveBeenCalledTimes( - 1, + expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith( + "telegram", ); - expect(hoisted.providerManager.stopDiscordProvider).toHaveBeenCalledTimes( - 1, + expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith( + "discord", ); - expect(hoisted.providerManager.startDiscordProvider).toHaveBeenCalledTimes( - 1, + expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith( + "discord", ); - expect(hoisted.providerManager.stopSignalProvider).toHaveBeenCalledTimes(1); - expect(hoisted.providerManager.startSignalProvider).toHaveBeenCalledTimes( - 1, + expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith("signal"); + expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith( + "signal", ); - expect(hoisted.providerManager.stopIMessageProvider).toHaveBeenCalledTimes( - 1, + expect(hoisted.providerManager.stopProvider).toHaveBeenCalledWith( + "imessage", ); - expect(hoisted.providerManager.startIMessageProvider).toHaveBeenCalledTimes( - 1, + expect(hoisted.providerManager.startProvider).toHaveBeenCalledWith( + "imessage", ); await server.close(); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index cbe94ba2a..140c4de8a 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -104,7 +104,16 @@ import { runtimeForLogger, } from "../logging.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js"; -import { defaultRuntime } from "../runtime.js"; +import { + listProviderPlugins, + normalizeProviderId, + type ProviderId, +} from "../providers/plugins/index.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { + isGatewayCliClient, + isWebchatClient, +} from "../utils/message-provider.js"; import { runOnboardingWizard } from "../wizard/onboarding.js"; import type { WizardSession } from "../wizard/session.js"; import { @@ -189,21 +198,19 @@ const logCron = log.child("cron"); const logReload = log.child("reload"); const logHooks = log.child("hooks"); const logWsControl = log.child("ws"); -const logWhatsApp = logProviders.child("whatsapp"); -const logTelegram = logProviders.child("telegram"); -const logDiscord = logProviders.child("discord"); -const logSlack = logProviders.child("slack"); -const logSignal = logProviders.child("signal"); -const logIMessage = logProviders.child("imessage"); -const logMSTeams = logProviders.child("msteams"); const canvasRuntime = runtimeForLogger(logCanvas); -const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp); -const telegramRuntimeEnv = runtimeForLogger(logTelegram); -const discordRuntimeEnv = runtimeForLogger(logDiscord); -const slackRuntimeEnv = runtimeForLogger(logSlack); -const signalRuntimeEnv = runtimeForLogger(logSignal); -const imessageRuntimeEnv = runtimeForLogger(logIMessage); -const msteamsRuntimeEnv = runtimeForLogger(logMSTeams); +const providerLogs = Object.fromEntries( + listProviderPlugins().map((plugin) => [ + plugin.id, + logProviders.child(plugin.id), + ]), +) as Record>; +const providerRuntimeEnvs = Object.fromEntries( + Object.entries(providerLogs).map(([id, logger]) => [ + id, + runtimeForLogger(logger), + ]), +) as Record; type GatewayModelChoice = ModelCatalogEntry; @@ -225,10 +232,11 @@ type Client = { presenceKey?: string; }; -const METHODS = [ +const BASE_METHODS = [ "health", "logs.tail", "providers.status", + "providers.logout", "status", "usage.status", "config.get", @@ -277,16 +285,18 @@ const METHODS = [ "send", "agent", "agent.wait", - "web.login.start", - "web.login.wait", - "web.logout", - "telegram.logout", // WebChat WebSocket-native chat methods "chat.history", "chat.abort", "chat.send", ]; +const PROVIDER_METHODS = listProviderPlugins().flatMap( + (plugin) => plugin.gatewayMethods ?? [], +); + +const METHODS = Array.from(new Set([...BASE_METHODS, ...PROVIDER_METHODS])); + const EVENTS = [ "agent", "chat", @@ -377,10 +387,10 @@ function buildSnapshot(): Snapshot { }; } -async function refreshHealthSnapshot(_opts?: { probe?: boolean }) { +async function refreshHealthSnapshot(opts?: { probe?: boolean }) { if (!healthRefresh) { healthRefresh = (async () => { - const snap = await getHealthSnapshot(undefined); + const snap = await getHealthSnapshot({ probe: opts?.probe }); healthCache = snap; healthVersion += 1; if (broadcastHealthUpdate) { @@ -782,39 +792,15 @@ export async function startGatewayServer( const providerManager = createProviderManager({ loadConfig, - logWhatsApp, - logTelegram, - logDiscord, - logSlack, - logSignal, - logIMessage, - logMSTeams, - whatsappRuntimeEnv, - telegramRuntimeEnv, - discordRuntimeEnv, - slackRuntimeEnv, - signalRuntimeEnv, - imessageRuntimeEnv, - msteamsRuntimeEnv, + providerLogs, + providerRuntimeEnvs, }); const { getRuntimeSnapshot, startProviders, - startWhatsAppProvider, - startTelegramProvider, - startDiscordProvider, - startSlackProvider, - startSignalProvider, - startIMessageProvider, - startMSTeamsProvider, - stopWhatsAppProvider, - stopTelegramProvider, - stopDiscordProvider, - stopSlackProvider, - stopSignalProvider, - stopIMessageProvider, - stopMSTeamsProvider, - markWhatsAppLoggedOut, + startProvider, + stopProvider, + markProviderLoggedOut, } = providerManager; const broadcast = ( @@ -1293,8 +1279,7 @@ export async function startGatewayServer( }); logWs("in", "open", { connId, remoteAddr }); const isWebchatConnect = (params: ConnectParams | null | undefined) => - params?.client?.mode === "webchat" || - params?.client?.name === "webchat-ui"; + isWebchatClient(params?.client); let handshakeState: "pending" | "connected" | "failed" = "pending"; let closeCause: string | undefined; let closeMeta: Record = {}; @@ -1476,6 +1461,8 @@ export async function startGatewayServer( const frame = parsed as RequestFrame; const connectParams = frame.params as ConnectParams; + const clientLabel = + connectParams.client.displayName ?? connectParams.client.id; // protocol negotiation const { minProtocol, maxProtocol } = connectParams; @@ -1485,13 +1472,14 @@ export async function startGatewayServer( ) { handshakeState = "failed"; logWsControl.warn( - `protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`, + `protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`, ); setCloseCause("protocol-mismatch", { minProtocol, maxProtocol, expectedProtocol: PROTOCOL_VERSION, - client: connectParams.client.name, + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, mode: connectParams.client.mode, version: connectParams.client.version, }); @@ -1520,7 +1508,7 @@ export async function startGatewayServer( if (!authResult.ok) { handshakeState = "failed"; logWsControl.warn( - `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`, + `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`, ); const authProvided = connectParams.auth?.token ? "token" @@ -1532,7 +1520,8 @@ export async function startGatewayServer( authProvided, authReason: authResult.reason, allowTailscale: resolvedAuth.allowTailscale, - client: connectParams.client.name, + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, mode: connectParams.client.mode, version: connectParams.client.version, }); @@ -1548,14 +1537,15 @@ export async function startGatewayServer( } const authMethod = authResult.method ?? "none"; - const shouldTrackPresence = connectParams.client.mode !== "cli"; + const shouldTrackPresence = !isGatewayCliClient(connectParams.client); const presenceKey = shouldTrackPresence ? connectParams.client.instanceId || connId : undefined; logWs("in", "connect", { connId, - client: connectParams.client.name, + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, version: connectParams.client.version, mode: connectParams.client.mode, instanceId: connectParams.client.instanceId, @@ -1565,13 +1555,16 @@ export async function startGatewayServer( if (isWebchatConnect(connectParams)) { logWsControl.info( - `webchat connected conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`, + `webchat connected conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version}`, ); } if (presenceKey) { upsertPresence(presenceKey, { - host: connectParams.client.name || os.hostname(), + host: + connectParams.client.displayName ?? + connectParams.client.id ?? + os.hostname(), ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr, version: connectParams.client.version, platform: connectParams.client.platform, @@ -1707,19 +1700,9 @@ export async function startGatewayServer( findRunningWizard, purgeWizardSession, getRuntimeSnapshot, - startWhatsAppProvider, - stopWhatsAppProvider, - startTelegramProvider, - stopTelegramProvider, - startDiscordProvider, - stopDiscordProvider, - startSlackProvider, - stopSlackProvider, - startSignalProvider, - stopSignalProvider, - startIMessageProvider, - stopIMessageProvider, - markWhatsAppLoggedOut, + startProvider, + stopProvider, + markProviderLoggedOut, wizardRunner, broadcastVoiceWakeChanged, }, @@ -1859,8 +1842,8 @@ export async function startGatewayServer( } } - // Launch configured providers (WhatsApp Web, Discord, Slack, Telegram) so gateway replies via the - // surface the message came from. Tests can opt out via CLAWDBOT_SKIP_PROVIDERS. + // Launch configured providers so gateway replies via the surface the message came from. + // Tests can opt out via CLAWDBOT_SKIP_PROVIDERS. if (process.env.CLAWDBOT_SKIP_PROVIDERS !== "1") { try { await startProviders(); @@ -1886,13 +1869,11 @@ export async function startGatewayServer( } const { cfg, entry } = loadSessionEntry(sessionKey); - const lastProvider = - entry?.lastProvider && entry.lastProvider !== "webchat" - ? entry.lastProvider - : undefined; + const lastProvider = entry?.lastProvider; const lastTo = entry?.lastTo?.trim(); const parsedTarget = resolveAnnounceTargetFromKey(sessionKey); - const provider = lastProvider ?? parsedTarget?.provider; + const providerRaw = lastProvider ?? parsedTarget?.provider; + const provider = providerRaw ? normalizeProviderId(providerRaw) : null; const to = lastTo || parsedTarget?.to; if (!provider || !to) { enqueueSystemEvent(message, { sessionKey }); @@ -1900,16 +1881,11 @@ export async function startGatewayServer( } const resolved = resolveOutboundTarget({ - provider: provider as - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "webchat", + provider, to, - allowFrom: cfg.whatsapp?.allowFrom ?? [], + cfg, + accountId: parsedTarget?.accountId ?? entry?.lastAccountId, + mode: "implicit", }); if (!resolved.ok) { enqueueSystemEvent(message, { sessionKey }); @@ -2011,59 +1987,13 @@ export async function startGatewayServer( "skipping provider reload (CLAWDBOT_SKIP_PROVIDERS=1)", ); } else { - const restartProvider = async ( - name: ProviderKind, - stop: () => Promise, - start: () => Promise, - ) => { + const restartProvider = async (name: ProviderKind) => { logProviders.info(`restarting ${name} provider`); - await stop(); - await start(); + await stopProvider(name); + await startProvider(name); }; - if (plan.restartProviders.has("whatsapp")) { - await restartProvider( - "whatsapp", - stopWhatsAppProvider, - startWhatsAppProvider, - ); - } - if (plan.restartProviders.has("telegram")) { - await restartProvider( - "telegram", - stopTelegramProvider, - startTelegramProvider, - ); - } - if (plan.restartProviders.has("discord")) { - await restartProvider( - "discord", - stopDiscordProvider, - startDiscordProvider, - ); - } - if (plan.restartProviders.has("slack")) { - await restartProvider("slack", stopSlackProvider, startSlackProvider); - } - if (plan.restartProviders.has("signal")) { - await restartProvider( - "signal", - stopSignalProvider, - startSignalProvider, - ); - } - if (plan.restartProviders.has("imessage")) { - await restartProvider( - "imessage", - stopIMessageProvider, - startIMessageProvider, - ); - } - if (plan.restartProviders.has("msteams")) { - await restartProvider( - "msteams", - stopMSTeamsProvider, - startMSTeamsProvider, - ); + for (const provider of plan.restartProviders) { + await restartProvider(provider); } } } @@ -2158,13 +2088,9 @@ export async function startGatewayServer( /* ignore */ } } - await stopWhatsAppProvider(); - await stopTelegramProvider(); - await stopDiscordProvider(); - await stopSlackProvider(); - await stopSignalProvider(); - await stopIMessageProvider(); - await stopMSTeamsProvider(); + for (const plugin of listProviderPlugins()) { + await stopProvider(plugin.id); + } await stopGmailWatcher(); cron.stop(); heartbeatRunner.stop(); diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index db619357e..8e351e8e2 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -8,6 +8,10 @@ import { resolveMainSessionKeyFromConfig } from "../config/sessions.js"; import { resetAgentRunContextForTest } from "../infra/agent-events.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { rawDataToString } from "../infra/ws.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import type { GatewayServerOptions } from "./server.js"; @@ -534,7 +538,8 @@ export async function connectReq( minProtocol?: number; maxProtocol?: number; client?: { - name: string; + id: string; + displayName?: string; version: string; platform: string; mode: string; @@ -553,10 +558,10 @@ export async function connectReq( minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION, client: opts?.client ?? { - name: "test", + id: GATEWAY_CLIENT_NAMES.TEST, version: "1.0.0", platform: "test", - mode: "test", + mode: GATEWAY_CLIENT_MODES.TEST, }, caps: [], auth: diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index c53435d09..d5a4d5df8 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -15,15 +15,18 @@ import { resolveAgentIdFromSessionKey, resolveMainSessionKey, resolveStorePath, - type SessionEntry, saveSessionStore, } from "../config/sessions.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging.js"; import { getQueueSize } from "../process/command-queue.js"; -import { webAuthExists } from "../providers/web/index.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../providers/plugins/index.js"; +import type { ProviderHeartbeatDeps } from "../providers/plugins/types.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { getActiveWebListener } from "../web/active-listener.js"; +import { INTERNAL_MESSAGE_PROVIDER } from "../utils/message-provider.js"; import { emitHeartbeatEvent } from "./heartbeat-events.js"; import { type HeartbeatRunResult, @@ -34,13 +37,12 @@ import type { OutboundSendDeps } from "./outbound/deliver.js"; import { deliverOutboundPayloads } from "./outbound/deliver.js"; import { resolveHeartbeatDeliveryTarget } from "./outbound/targets.js"; -type HeartbeatDeps = OutboundSendDeps & { - runtime?: RuntimeEnv; - getQueueSize?: (lane?: string) => number; - nowMs?: () => number; - webAuthExists?: () => Promise; - hasActiveWebListener?: () => boolean; -}; +type HeartbeatDeps = OutboundSendDeps & + ProviderHeartbeatDeps & { + runtime?: RuntimeEnv; + getQueueSize?: (lane?: string) => number; + nowMs?: () => number; + }; const log = createSubsystemLogger("gateway/heartbeat"); let heartbeatsEnabled = true; @@ -129,13 +131,12 @@ function resolveHeartbeatReasoningPayloads( function resolveHeartbeatSender(params: { allowFrom: Array; lastTo?: string; - lastProvider?: SessionEntry["lastProvider"]; + provider?: string | null; }) { - const { allowFrom, lastTo, lastProvider } = params; + const { allowFrom, lastTo, provider } = params; const candidates = [ lastTo?.trim(), - lastProvider === "telegram" && lastTo ? `telegram:${lastTo}` : undefined, - lastProvider === "whatsapp" && lastTo ? `whatsapp:${lastTo}` : undefined, + provider && lastTo ? `${provider}:${lastTo}` : undefined, ].filter((val): val is string => Boolean(val?.trim())); const allowList = allowFrom @@ -157,26 +158,6 @@ function resolveHeartbeatSender(params: { return candidates[0] ?? "heartbeat"; } -async function resolveWhatsAppReadiness( - cfg: ClawdbotConfig, - deps?: HeartbeatDeps, -): Promise<{ ok: boolean; reason: string }> { - if (cfg.web?.enabled === false) { - return { ok: false, reason: "whatsapp-disabled" }; - } - const authExists = await (deps?.webAuthExists ?? webAuthExists)(); - if (!authExists) { - return { ok: false, reason: "whatsapp-not-linked" }; - } - const listenerActive = deps?.hasActiveWebListener - ? deps.hasActiveWebListener() - : Boolean(getActiveWebListener()); - if (!listenerActive) { - return { ok: false, reason: "whatsapp-not-running" }; - } - return { ok: true, reason: "ok" }; -} - async function restoreHeartbeatUpdatedAt(params: { storePath: string; sessionKey: string; @@ -239,11 +220,24 @@ export async function runHeartbeatOnce(opts: { const startedAt = opts.deps?.nowMs?.() ?? Date.now(); const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg); const previousUpdatedAt = entry?.updatedAt; - const allowFrom = cfg.whatsapp?.allowFrom ?? []; + const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry }); + const lastProvider = + entry?.lastProvider && entry.lastProvider !== INTERNAL_MESSAGE_PROVIDER + ? normalizeProviderId(entry.lastProvider) + : undefined; + const senderProvider = + delivery.provider !== "none" ? delivery.provider : lastProvider; + const senderAllowFrom = senderProvider + ? (getProviderPlugin(senderProvider)?.config.resolveAllowFrom?.({ + cfg, + accountId: + senderProvider === lastProvider ? entry?.lastAccountId : undefined, + }) ?? []) + : []; const sender = resolveHeartbeatSender({ - allowFrom, + allowFrom: senderAllowFrom, lastTo: entry?.lastTo, - lastProvider: entry?.lastProvider, + provider: senderProvider, }); const prompt = resolveHeartbeatPrompt(cfg); const ctx = { @@ -311,7 +305,6 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } - const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry }); const mediaUrls = replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []); @@ -334,8 +327,15 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } - if (delivery.provider === "whatsapp") { - const readiness = await resolveWhatsAppReadiness(cfg, opts.deps); + const deliveryAccountId = + delivery.provider === lastProvider ? entry?.lastAccountId : undefined; + const heartbeatPlugin = getProviderPlugin(delivery.provider); + if (heartbeatPlugin?.heartbeat?.checkReady) { + const readiness = await heartbeatPlugin.heartbeat.checkReady({ + cfg, + accountId: deliveryAccountId, + deps: opts.deps, + }); if (!readiness.ok) { emitHeartbeatEvent({ status: "skipped", @@ -344,7 +344,8 @@ export async function runHeartbeatOnce(opts: { durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, }); - log.info("heartbeat: whatsapp not ready", { + log.info("heartbeat: provider not ready", { + provider: delivery.provider, reason: readiness.reason, }); return { status: "skipped", reason: readiness.reason }; @@ -355,6 +356,7 @@ export async function runHeartbeatOnce(opts: { cfg, provider: delivery.provider, to: delivery.to, + accountId: deliveryAccountId, payloads: [ ...reasoningPayloads, ...(shouldSkipMain diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 12e31f73c..92b9c83b7 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,24 +1,18 @@ -import { - chunkMarkdownText, - chunkText, - resolveTextChunkLimit, -} from "../../auto-reply/chunk.js"; +import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; -import { sendMessageDiscord } from "../../discord/send.js"; -import { sendMessageIMessage } from "../../imessage/send.js"; -import { sendMessageMSTeams } from "../../msteams/send.js"; -import { normalizeAccountId } from "../../routing/session-key.js"; -import { sendMessageSignal } from "../../signal/send.js"; -import { sendMessageSlack } from "../../slack/send.js"; -import { sendMessageTelegram } from "../../telegram/send.js"; -import { sendMessageWhatsApp } from "../../web/outbound.js"; +import type { sendMessageDiscord } from "../../discord/send.js"; +import type { sendMessageIMessage } from "../../imessage/send.js"; +import { loadProviderOutboundAdapter } from "../../providers/plugins/outbound/load.js"; +import type { ProviderOutboundAdapter } from "../../providers/plugins/types.js"; +import type { sendMessageSignal } from "../../signal/send.js"; +import type { sendMessageSlack } from "../../slack/send.js"; +import type { sendMessageTelegram } from "../../telegram/send.js"; +import type { sendMessageWhatsApp } from "../../web/outbound.js"; import type { NormalizedOutboundPayload } from "./payloads.js"; import { normalizeOutboundPayloads } from "./payloads.js"; import type { OutboundProvider } from "./targets.js"; -const MB = 1024 * 1024; - export type { NormalizedOutboundPayload } from "./payloads.js"; export { normalizeOutboundPayloads } from "./payloads.js"; @@ -36,32 +30,24 @@ export type OutboundSendDeps = { ) => Promise<{ messageId: string; conversationId: string }>; }; -export type OutboundDeliveryResult = - | { provider: "whatsapp"; messageId: string; toJid: string } - | { provider: "telegram"; messageId: string; chatId: string } - | { provider: "discord"; messageId: string; channelId: string } - | { provider: "slack"; messageId: string; channelId: string } - | { provider: "signal"; messageId: string; timestamp?: number } - | { provider: "imessage"; messageId: string } - | { provider: "msteams"; messageId: string; conversationId: string }; +export type OutboundDeliveryResult = { + provider: Exclude; + messageId: string; + chatId?: string; + channelId?: string; + conversationId?: string; + timestamp?: number; + toJid?: string; + pollId?: string; + // Provider docking: stash provider-specific fields here to avoid core type churn. + meta?: Record; +}; type Chunker = (text: string, limit: number) => string[]; -const providerCaps: Record< - Exclude, - { chunker: Chunker | null } -> = { - whatsapp: { chunker: chunkText }, - telegram: { chunker: chunkMarkdownText }, - discord: { chunker: null }, - slack: { chunker: null }, - signal: { chunker: chunkText }, - imessage: { chunker: chunkText }, - msteams: { chunker: chunkMarkdownText }, -}; - type ProviderHandler = { chunker: Chunker | null; + textChunkLimit?: number; sendText: (text: string) => Promise; sendMedia: ( caption: string, @@ -69,165 +55,87 @@ type ProviderHandler = { ) => Promise; }; -function resolveMediaMaxBytes( - cfg: ClawdbotConfig, - provider: "signal" | "imessage", - accountId?: string | null, -): number | undefined { - const normalizedAccountId = normalizeAccountId(accountId); - const providerLimit = - provider === "signal" - ? (cfg.signal?.accounts?.[normalizedAccountId]?.mediaMaxMb ?? - cfg.signal?.mediaMaxMb) - : (cfg.imessage?.accounts?.[normalizedAccountId]?.mediaMaxMb ?? - cfg.imessage?.mediaMaxMb); - if (providerLimit) return providerLimit * MB; - if (cfg.agents?.defaults?.mediaMaxMb) { - return cfg.agents.defaults.mediaMaxMb * MB; +function throwIfAborted(abortSignal?: AbortSignal): void { + if (abortSignal?.aborted) { + throw new Error("Outbound delivery aborted"); } - return undefined; } -function createProviderHandler(params: { +// Provider docking: outbound delivery delegates to plugin.outbound adapters. +async function createProviderHandler(params: { cfg: ClawdbotConfig; provider: Exclude; to: string; accountId?: string; - deps: Required; -}): ProviderHandler { - const { cfg, to, deps } = params; - const rawAccountId = params.accountId; - const accountId = normalizeAccountId(rawAccountId); - const signalMaxBytes = - params.provider === "signal" - ? resolveMediaMaxBytes(cfg, "signal", accountId) - : undefined; - const imessageMaxBytes = - params.provider === "imessage" - ? resolveMediaMaxBytes(cfg, "imessage", accountId) - : undefined; + replyToId?: string | null; + threadId?: number | null; + deps?: OutboundSendDeps; + gifPlayback?: boolean; +}): Promise { + const outbound = await loadProviderOutboundAdapter(params.provider); + if (!outbound?.sendText || !outbound?.sendMedia) { + throw new Error(`Outbound not configured for provider: ${params.provider}`); + } + const handler = createPluginHandler({ + outbound, + cfg: params.cfg, + provider: params.provider, + to: params.to, + accountId: params.accountId, + replyToId: params.replyToId, + threadId: params.threadId, + deps: params.deps, + gifPlayback: params.gifPlayback, + }); + if (!handler) { + throw new Error(`Outbound not configured for provider: ${params.provider}`); + } + return handler; +} - const handlers: Record, ProviderHandler> = { - whatsapp: { - chunker: providerCaps.whatsapp.chunker, - sendText: async (text) => ({ - provider: "whatsapp", - ...(await deps.sendWhatsApp(to, text, { - verbose: false, - accountId: rawAccountId, - })), +function createPluginHandler(params: { + outbound?: ProviderOutboundAdapter; + cfg: ClawdbotConfig; + provider: Exclude; + to: string; + accountId?: string; + replyToId?: string | null; + threadId?: number | null; + deps?: OutboundSendDeps; + gifPlayback?: boolean; +}): ProviderHandler | null { + const outbound = params.outbound; + if (!outbound?.sendText || !outbound?.sendMedia) return null; + const sendText = outbound.sendText; + const sendMedia = outbound.sendMedia; + const chunker = outbound.chunker ?? null; + return { + chunker, + textChunkLimit: outbound.textChunkLimit, + sendText: async (text) => + sendText({ + cfg: params.cfg, + to: params.to, + text, + accountId: params.accountId, + replyToId: params.replyToId, + threadId: params.threadId, + gifPlayback: params.gifPlayback, + deps: params.deps, }), - sendMedia: async (caption, mediaUrl) => ({ - provider: "whatsapp", - ...(await deps.sendWhatsApp(to, caption, { - verbose: false, - mediaUrl, - accountId: rawAccountId, - })), + sendMedia: async (caption, mediaUrl) => + sendMedia({ + cfg: params.cfg, + to: params.to, + text: caption, + mediaUrl, + accountId: params.accountId, + replyToId: params.replyToId, + threadId: params.threadId, + gifPlayback: params.gifPlayback, + deps: params.deps, }), - }, - telegram: { - chunker: providerCaps.telegram.chunker, - sendText: async (text) => ({ - provider: "telegram", - ...(await deps.sendTelegram(to, text, { - verbose: false, - accountId: rawAccountId, - })), - }), - sendMedia: async (caption, mediaUrl) => ({ - provider: "telegram", - ...(await deps.sendTelegram(to, caption, { - verbose: false, - mediaUrl, - accountId: rawAccountId, - })), - }), - }, - discord: { - chunker: providerCaps.discord.chunker, - sendText: async (text) => ({ - provider: "discord", - ...(await deps.sendDiscord(to, text, { - verbose: false, - accountId: rawAccountId, - })), - }), - sendMedia: async (caption, mediaUrl) => ({ - provider: "discord", - ...(await deps.sendDiscord(to, caption, { - verbose: false, - mediaUrl, - accountId: rawAccountId, - })), - }), - }, - slack: { - chunker: providerCaps.slack.chunker, - sendText: async (text) => ({ - provider: "slack", - ...(await deps.sendSlack(to, text, { - accountId: rawAccountId, - })), - }), - sendMedia: async (caption, mediaUrl) => ({ - provider: "slack", - ...(await deps.sendSlack(to, caption, { - mediaUrl, - accountId: rawAccountId, - })), - }), - }, - signal: { - chunker: providerCaps.signal.chunker, - sendText: async (text) => ({ - provider: "signal", - ...(await deps.sendSignal(to, text, { - maxBytes: signalMaxBytes, - accountId: rawAccountId, - })), - }), - sendMedia: async (caption, mediaUrl) => ({ - provider: "signal", - ...(await deps.sendSignal(to, caption, { - mediaUrl, - maxBytes: signalMaxBytes, - accountId: rawAccountId, - })), - }), - }, - imessage: { - chunker: providerCaps.imessage.chunker, - sendText: async (text) => ({ - provider: "imessage", - ...(await deps.sendIMessage(to, text, { - maxBytes: imessageMaxBytes, - accountId: rawAccountId, - })), - }), - sendMedia: async (caption, mediaUrl) => ({ - provider: "imessage", - ...(await deps.sendIMessage(to, caption, { - mediaUrl, - maxBytes: imessageMaxBytes, - accountId: rawAccountId, - })), - }), - }, - msteams: { - chunker: providerCaps.msteams.chunker, - sendText: async (text) => ({ - provider: "msteams", - ...(await deps.sendMSTeams(to, text)), - }), - sendMedia: async (caption, mediaUrl) => ({ - provider: "msteams", - ...(await deps.sendMSTeams(to, caption, { mediaUrl })), - }), - }, }; - - return handlers[params.provider]; } export async function deliverOutboundPayloads(params: { @@ -236,45 +144,44 @@ export async function deliverOutboundPayloads(params: { to: string; accountId?: string; payloads: ReplyPayload[]; + replyToId?: string | null; + threadId?: number | null; deps?: OutboundSendDeps; + gifPlayback?: boolean; + abortSignal?: AbortSignal; bestEffort?: boolean; onError?: (err: unknown, payload: NormalizedOutboundPayload) => void; onPayload?: (payload: NormalizedOutboundPayload) => void; }): Promise { const { cfg, provider, to, payloads } = params; const accountId = params.accountId; - const defaultSendMSTeams = async ( - to: string, - text: string, - opts?: { mediaUrl?: string }, - ) => sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl }); - const deps = { - sendWhatsApp: params.deps?.sendWhatsApp ?? sendMessageWhatsApp, - sendTelegram: params.deps?.sendTelegram ?? sendMessageTelegram, - sendDiscord: params.deps?.sendDiscord ?? sendMessageDiscord, - sendSlack: params.deps?.sendSlack ?? sendMessageSlack, - sendSignal: params.deps?.sendSignal ?? sendMessageSignal, - sendIMessage: params.deps?.sendIMessage ?? sendMessageIMessage, - sendMSTeams: params.deps?.sendMSTeams ?? defaultSendMSTeams, - }; + const deps = params.deps; + const abortSignal = params.abortSignal; const results: OutboundDeliveryResult[] = []; - const handler = createProviderHandler({ + const handler = await createProviderHandler({ cfg, provider, to, deps, accountId, + replyToId: params.replyToId, + threadId: params.threadId, + gifPlayback: params.gifPlayback, }); const textLimit = handler.chunker - ? resolveTextChunkLimit(cfg, provider, accountId) + ? resolveTextChunkLimit(cfg, provider, accountId, { + fallbackLimit: handler.textChunkLimit, + }) : undefined; const sendTextChunks = async (text: string) => { + throwIfAborted(abortSignal); if (!handler.chunker || textLimit === undefined) { results.push(await handler.sendText(text)); return; } for (const chunk of handler.chunker(text, textLimit)) { + throwIfAborted(abortSignal); results.push(await handler.sendText(chunk)); } }; @@ -282,6 +189,7 @@ export async function deliverOutboundPayloads(params: { const normalizedPayloads = normalizeOutboundPayloads(payloads); for (const payload of normalizedPayloads) { try { + throwIfAborted(abortSignal); params.onPayload?.(payload); if (payload.mediaUrls.length === 0) { await sendTextChunks(payload.text); @@ -290,6 +198,7 @@ export async function deliverOutboundPayloads(params: { let first = true; for (const url of payload.mediaUrls) { + throwIfAborted(abortSignal); const caption = first ? payload.text : ""; first = false; results.push(await handler.sendMedia(caption, url)); diff --git a/src/infra/outbound/format.test.ts b/src/infra/outbound/format.test.ts index 145d8b5eb..4410c4b86 100644 --- a/src/infra/outbound/format.test.ts +++ b/src/infra/outbound/format.test.ts @@ -9,7 +9,7 @@ import { describe("formatOutboundDeliverySummary", () => { it("falls back when result is missing", () => { expect(formatOutboundDeliverySummary("telegram")).toBe( - "✅ Sent via telegram. Message ID: unknown", + "✅ Sent via Telegram. Message ID: unknown", ); expect(formatOutboundDeliverySummary("imessage")).toBe( "✅ Sent via iMessage. Message ID: unknown", @@ -23,7 +23,7 @@ describe("formatOutboundDeliverySummary", () => { messageId: "m1", chatId: "c1", }), - ).toBe("✅ Sent via telegram. Message ID: m1 (chat c1)"); + ).toBe("✅ Sent via Telegram. Message ID: m1 (chat c1)"); expect( formatOutboundDeliverySummary("discord", { @@ -31,7 +31,7 @@ describe("formatOutboundDeliverySummary", () => { messageId: "d1", channelId: "chan", }), - ).toBe("✅ Sent via discord. Message ID: d1 (channel chan)"); + ).toBe("✅ Sent via Discord. Message ID: d1 (channel chan)"); }); }); diff --git a/src/infra/outbound/format.ts b/src/infra/outbound/format.ts index 4c02307bf..2f74f1969 100644 --- a/src/infra/outbound/format.ts +++ b/src/infra/outbound/format.ts @@ -1,3 +1,5 @@ +import { getProviderPlugin } from "../../providers/plugins/index.js"; +import type { ProviderId } from "../../providers/plugins/types.js"; import type { OutboundDeliveryResult } from "./deliver.js"; export type OutboundDeliveryJson = { @@ -11,6 +13,7 @@ export type OutboundDeliveryJson = { conversationId?: string; timestamp?: number; toJid?: string; + meta?: Record; }; type OutboundDeliveryMeta = { @@ -20,10 +23,11 @@ type OutboundDeliveryMeta = { conversationId?: string; timestamp?: number; toJid?: string; + meta?: Record; }; const resolveProviderLabel = (provider: string) => - provider === "imessage" ? "iMessage" : provider; + getProviderPlugin(provider as ProviderId)?.meta.label ?? provider; export function formatOutboundDeliverySummary( provider: string, @@ -79,6 +83,9 @@ export function buildOutboundDeliveryJson(params: { if (result && "toJid" in result && result.toJid !== undefined) { payload.toJid = result.toJid; } + if (result && "meta" in result && result.meta !== undefined) { + payload.meta = result.meta; + } return payload; } diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index e3090ec6d..9afb13537 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -3,22 +3,33 @@ import { loadConfig } from "../../config/config.js"; import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; import type { PollInput } from "../../polls.js"; import { normalizePollInput } from "../../polls.js"; -import { normalizeMessageProvider } from "../../utils/message-provider.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../../providers/plugins/index.js"; +import type { ProviderId } from "../../providers/plugins/types.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + type GatewayClientMode, + type GatewayClientName, +} from "../../utils/message-provider.js"; import { deliverOutboundPayloads, type OutboundDeliveryResult, type OutboundSendDeps, } from "./deliver.js"; +import { resolveMessageProviderSelection } from "./provider-selection.js"; +import type { OutboundProvider } from "./targets.js"; import { resolveOutboundTarget } from "./targets.js"; -type GatewayCallMode = "cli" | "agent"; - export type MessageGatewayOptions = { url?: string; token?: string; timeoutMs?: number; - clientName?: GatewayCallMode; - mode?: GatewayCallMode; + clientName?: GatewayClientName; + clientDisplayName?: string; + mode?: GatewayClientMode; }; type MessageSendParams = { @@ -84,47 +95,56 @@ function resolveGatewayOptions(opts?: MessageGatewayOptions) { typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) ? Math.max(1, Math.floor(opts.timeoutMs)) : 10_000, - clientName: opts?.clientName ?? "cli", - mode: opts?.mode ?? "cli", + clientName: opts?.clientName ?? GATEWAY_CLIENT_NAMES.CLI, + clientDisplayName: opts?.clientDisplayName, + mode: opts?.mode ?? GATEWAY_CLIENT_MODES.CLI, }; } export async function sendMessage( params: MessageSendParams, ): Promise { - const provider = normalizeMessageProvider(params.provider) ?? "whatsapp"; const cfg = params.cfg ?? loadConfig(); + const provider = params.provider?.trim() + ? normalizeProviderId(params.provider) + : (await resolveMessageProviderSelection({ cfg })).provider; + if (!provider) { + throw new Error(`Unknown provider: ${params.provider}`); + } + const plugin = getProviderPlugin(provider as ProviderId); + if (!plugin) { + throw new Error(`Unknown provider: ${provider}`); + } + const deliveryMode = plugin.outbound?.deliveryMode ?? "direct"; if (params.dryRun) { return { provider, to: params.to, - via: provider === "whatsapp" ? "gateway" : "direct", + via: deliveryMode === "gateway" ? "gateway" : "direct", mediaUrl: params.mediaUrl ?? null, dryRun: true, }; } - if ( - provider === "telegram" || - provider === "discord" || - provider === "slack" || - provider === "signal" || - provider === "imessage" || - provider === "msteams" - ) { + if (deliveryMode !== "gateway") { + const outboundProvider = provider as Exclude; const resolvedTarget = resolveOutboundTarget({ - provider, + provider: outboundProvider, to: params.to, + cfg, + accountId: params.accountId, + mode: "explicit", }); if (!resolvedTarget.ok) throw resolvedTarget.error; const results = await deliverOutboundPayloads({ cfg, - provider, + provider: outboundProvider, to: resolvedTarget.to, accountId: params.accountId, payloads: [{ text: params.content, mediaUrl: params.mediaUrl }], + gifPlayback: params.gifPlayback, deps: params.deps, bestEffort: params.bestEffort, }); @@ -154,6 +174,7 @@ export async function sendMessage( }, timeoutMs: gateway.timeoutMs, clientName: gateway.clientName, + clientDisplayName: gateway.clientDisplayName, mode: gateway.mode, }); @@ -169,13 +190,12 @@ export async function sendMessage( export async function sendPoll( params: MessagePollParams, ): Promise { - const provider = normalizeMessageProvider(params.provider) ?? "whatsapp"; - if ( - provider !== "whatsapp" && - provider !== "discord" && - provider !== "msteams" - ) { - throw new Error(`Unsupported poll provider: ${provider}`); + const cfg = params.cfg ?? loadConfig(); + const provider = params.provider?.trim() + ? normalizeProviderId(params.provider) + : (await resolveMessageProviderSelection({ cfg })).provider; + if (!provider) { + throw new Error(`Unknown provider: ${params.provider}`); } const pollInput: PollInput = { @@ -184,8 +204,14 @@ export async function sendPoll( maxSelections: params.maxSelections, durationHours: params.durationHours, }; - const maxOptions = provider === "discord" ? 10 : 12; - const normalized = normalizePollInput(pollInput, { maxOptions }); + const plugin = getProviderPlugin(provider as ProviderId); + const outbound = plugin?.outbound; + if (!outbound?.sendPoll) { + throw new Error(`Unsupported poll provider: ${provider}`); + } + const normalized = outbound.pollMaxOptions + ? normalizePollInput(pollInput, { maxOptions: outbound.pollMaxOptions }) + : normalizePollInput(pollInput); if (params.dryRun) { return { @@ -222,6 +248,7 @@ export async function sendPoll( }, timeoutMs: gateway.timeoutMs, clientName: gateway.clientName, + clientDisplayName: gateway.clientDisplayName, mode: gateway.mode, }); diff --git a/src/infra/outbound/provider-selection.ts b/src/infra/outbound/provider-selection.ts index 069e6ff86..11d542515 100644 --- a/src/infra/outbound/provider-selection.ts +++ b/src/infra/outbound/provider-selection.ts @@ -1,20 +1,11 @@ import type { ClawdbotConfig } from "../../config/config.js"; -import { listEnabledDiscordAccounts } from "../../discord/accounts.js"; -import { listEnabledIMessageAccounts } from "../../imessage/accounts.js"; -import { resolveMSTeamsCredentials } from "../../msteams/token.js"; -import { listEnabledSignalAccounts } from "../../signal/accounts.js"; -import { listEnabledSlackAccounts } from "../../slack/accounts.js"; -import { listEnabledTelegramAccounts } from "../../telegram/accounts.js"; +import { listProviderPlugins } from "../../providers/plugins/index.js"; +import type { ProviderPlugin } from "../../providers/plugins/types.js"; import { DELIVERABLE_MESSAGE_PROVIDERS, type DeliverableMessageProvider, normalizeMessageProvider, } from "../../utils/message-provider.js"; -import { - listEnabledWhatsAppAccounts, - resolveWhatsAppAccount, -} from "../../web/accounts.js"; -import { webAuthExists } from "../../web/session.js"; export type MessageProviderId = DeliverableMessageProvider; @@ -24,60 +15,43 @@ function isKnownProvider(value: string): value is MessageProviderId { return (MESSAGE_PROVIDERS as readonly string[]).includes(value); } -async function isWhatsAppConfigured(cfg: ClawdbotConfig): Promise { - const accounts = listEnabledWhatsAppAccounts(cfg); - if (accounts.length === 0) { - const fallback = resolveWhatsAppAccount({ cfg }); - return await webAuthExists(fallback.authDir); - } - for (const account of accounts) { - if (await webAuthExists(account.authDir)) return true; +function isAccountEnabled(account: unknown): boolean { + if (!account || typeof account !== "object") return true; + const enabled = (account as { enabled?: boolean }).enabled; + return enabled !== false; +} + +async function isPluginConfigured( + plugin: ProviderPlugin, + cfg: ClawdbotConfig, +): Promise { + const accountIds = plugin.config.listAccountIds(cfg); + if (accountIds.length === 0) return false; + + for (const accountId of accountIds) { + const account = plugin.config.resolveAccount(cfg, accountId); + const enabled = plugin.config.isEnabled + ? plugin.config.isEnabled(account, cfg) + : isAccountEnabled(account); + if (!enabled) continue; + if (!plugin.config.isConfigured) return true; + const configured = await plugin.config.isConfigured(account, cfg); + if (configured) return true; } + return false; } -function isTelegramConfigured(cfg: ClawdbotConfig): boolean { - return listEnabledTelegramAccounts(cfg).some( - (account) => account.token.trim().length > 0, - ); -} - -function isDiscordConfigured(cfg: ClawdbotConfig): boolean { - return listEnabledDiscordAccounts(cfg).some( - (account) => account.token.trim().length > 0, - ); -} - -function isSlackConfigured(cfg: ClawdbotConfig): boolean { - return listEnabledSlackAccounts(cfg).some( - (account) => (account.botToken ?? "").trim().length > 0, - ); -} - -function isSignalConfigured(cfg: ClawdbotConfig): boolean { - return listEnabledSignalAccounts(cfg).some((account) => account.configured); -} - -function isIMessageConfigured(cfg: ClawdbotConfig): boolean { - return listEnabledIMessageAccounts(cfg).some((account) => account.configured); -} - -function isMSTeamsConfigured(cfg: ClawdbotConfig): boolean { - if (!cfg.msteams || cfg.msteams.enabled === false) return false; - return Boolean(resolveMSTeamsCredentials(cfg.msteams)); -} - export async function listConfiguredMessageProviders( cfg: ClawdbotConfig, ): Promise { const providers: MessageProviderId[] = []; - if (await isWhatsAppConfigured(cfg)) providers.push("whatsapp"); - if (isTelegramConfigured(cfg)) providers.push("telegram"); - if (isDiscordConfigured(cfg)) providers.push("discord"); - if (isSlackConfigured(cfg)) providers.push("slack"); - if (isSignalConfigured(cfg)) providers.push("signal"); - if (isIMessageConfigured(cfg)) providers.push("imessage"); - if (isMSTeamsConfigured(cfg)) providers.push("msteams"); + for (const plugin of listProviderPlugins()) { + if (!isKnownProvider(plugin.id)) continue; + if (await isPluginConfigured(plugin, cfg)) { + providers.push(plugin.id); + } + } return providers; } diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 44c548312..3949d9168 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -1,8 +1,20 @@ import { describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../../config/config.js"; import { resolveOutboundTarget } from "./targets.js"; describe("resolveOutboundTarget", () => { + it("falls back to whatsapp allowFrom via config", () => { + const cfg: ClawdbotConfig = { whatsapp: { allowFrom: ["+1555"] } }; + const res = resolveOutboundTarget({ + provider: "whatsapp", + to: "", + cfg, + mode: "explicit", + }); + expect(res).toEqual({ ok: true, to: "+1555" }); + }); + it.each([ { name: "normalizes whatsapp target when provided", diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 63656e837..4b8e3ec1e 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,14 +1,18 @@ import type { ClawdbotConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { + getProviderPlugin, + normalizeProviderId, +} from "../../providers/plugins/index.js"; +import type { + ProviderId, + ProviderOutboundTargetMode, +} from "../../providers/plugins/types.js"; import type { DeliverableMessageProvider, GatewayMessageProvider, } from "../../utils/message-provider.js"; -import { normalizeE164 } from "../../utils.js"; -import { - isWhatsAppGroupJid, - normalizeWhatsAppTarget, -} from "../../whatsapp/normalize.js"; +import { INTERNAL_MESSAGE_PROVIDER } from "../../utils/message-provider.js"; export type OutboundProvider = DeliverableMessageProvider | "none"; @@ -24,117 +28,60 @@ export type OutboundTargetResolution = | { ok: true; to: string } | { ok: false; error: Error }; -export function normalizeOutboundTarget(params: { - provider: GatewayMessageProvider; - to?: string; - allowFrom?: string[]; -}): OutboundTargetResolution { - const trimmed = params.to?.trim() || ""; - if (params.provider === "whatsapp") { - if (trimmed) { - const normalized = normalizeWhatsAppTarget(trimmed); - if (!normalized) { - return { - ok: false, - error: new Error( - "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", - ), - }; - } - return { ok: true, to: normalized }; - } - const fallback = params.allowFrom?.[0]?.trim(); - if (fallback) { - const normalized = normalizeWhatsAppTarget(fallback); - if (normalized) { - return { ok: true, to: normalized }; - } - } - return { - ok: false, - error: new Error( - "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", - ), - }; - } - if (params.provider === "telegram") { - if (!trimmed) { - return { - ok: false, - error: new Error("Delivering to Telegram requires --to "), - }; - } - return { ok: true, to: trimmed }; - } - if (params.provider === "discord") { - if (!trimmed) { - return { - ok: false, - error: new Error( - "Delivering to Discord requires --to ", - ), - }; - } - return { ok: true, to: trimmed }; - } - if (params.provider === "slack") { - if (!trimmed) { - return { - ok: false, - error: new Error( - "Delivering to Slack requires --to ", - ), - }; - } - return { ok: true, to: trimmed }; - } - if (params.provider === "signal") { - if (!trimmed) { - return { - ok: false, - error: new Error( - "Delivering to Signal requires --to ", - ), - }; - } - return { ok: true, to: trimmed }; - } - if (params.provider === "imessage") { - if (!trimmed) { - return { - ok: false, - error: new Error( - "Delivering to iMessage requires --to ", - ), - }; - } - return { ok: true, to: trimmed }; - } - if (params.provider === "msteams") { - if (!trimmed) { - return { - ok: false, - error: new Error( - "Delivering to MS Teams requires --to ", - ), - }; - } - return { ok: true, to: trimmed }; - } - return { - ok: false, - error: new Error( - "Delivering to WebChat is not supported via `clawdbot agent`; use WhatsApp/Telegram or run with --deliver=false.", - ), - }; -} - +// Provider docking: prefer plugin.outbound.resolveTarget + allowFrom to normalize destinations. export function resolveOutboundTarget(params: { provider: GatewayMessageProvider; to?: string; allowFrom?: string[]; + cfg?: ClawdbotConfig; + accountId?: string | null; + mode?: ProviderOutboundTargetMode; }): OutboundTargetResolution { - return normalizeOutboundTarget(params); + if (params.provider === INTERNAL_MESSAGE_PROVIDER) { + return { + ok: false, + error: new Error( + "Delivering to WebChat is not supported via `clawdbot agent`; use WhatsApp/Telegram or run with --deliver=false.", + ), + }; + } + + const plugin = getProviderPlugin(params.provider as ProviderId); + if (!plugin) { + return { + ok: false, + error: new Error(`Unsupported provider: ${params.provider}`), + }; + } + + const allowFrom = + params.allowFrom ?? + (params.cfg && plugin.config.resolveAllowFrom + ? plugin.config.resolveAllowFrom({ + cfg: params.cfg, + accountId: params.accountId ?? undefined, + }) + : undefined); + + const resolveTarget = plugin.outbound?.resolveTarget; + if (resolveTarget) { + return resolveTarget({ + cfg: params.cfg, + to: params.to, + allowFrom, + accountId: params.accountId ?? undefined, + mode: params.mode ?? "explicit", + }); + } + + const trimmed = params.to?.trim(); + if (trimmed) { + return { ok: true, to: trimmed }; + } + return { + ok: false, + error: new Error(`Delivering to ${plugin.meta.label} requires --to`), + }; } export function resolveHeartbeatDeliveryTarget(params: { @@ -143,18 +90,14 @@ export function resolveHeartbeatDeliveryTarget(params: { }): OutboundTarget { const { cfg, entry } = params; const rawTarget = cfg.agents?.defaults?.heartbeat?.target; - const target: HeartbeatTarget = - rawTarget === "whatsapp" || - rawTarget === "telegram" || - rawTarget === "discord" || - rawTarget === "slack" || - rawTarget === "signal" || - rawTarget === "imessage" || - rawTarget === "msteams" || - rawTarget === "none" || - rawTarget === "last" - ? rawTarget - : "last"; + let target: HeartbeatTarget = "last"; + if (rawTarget === "none" || rawTarget === "last") { + target = rawTarget; + } else if (typeof rawTarget === "string") { + const normalized = normalizeProviderId(rawTarget); + if (normalized) target = normalized; + } + if (target === "none") { return { provider: "none", reason: "target-none" }; } @@ -166,31 +109,11 @@ export function resolveHeartbeatDeliveryTarget(params: { : undefined; const lastProvider = - entry?.lastProvider && entry.lastProvider !== "webchat" - ? entry.lastProvider + entry?.lastProvider && entry.lastProvider !== INTERNAL_MESSAGE_PROVIDER + ? normalizeProviderId(entry.lastProvider) : undefined; const lastTo = typeof entry?.lastTo === "string" ? entry.lastTo.trim() : ""; - - const provider: - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams" - | undefined = - target === "last" - ? lastProvider - : target === "whatsapp" || - target === "telegram" || - target === "discord" || - target === "slack" || - target === "signal" || - target === "imessage" || - target === "msteams" - ? target - : undefined; + const provider = target === "last" ? lastProvider : target; const to = explicitTo || @@ -201,28 +124,35 @@ export function resolveHeartbeatDeliveryTarget(params: { return { provider: "none", reason: "no-target" }; } - if (provider !== "whatsapp") { - const resolved = normalizeOutboundTarget({ provider, to }); - return resolved.ok - ? { provider, to: resolved.to } - : { provider: "none", reason: "no-target" }; - } - - const rawAllow = cfg.whatsapp?.allowFrom ?? []; - const resolved = normalizeOutboundTarget({ - provider: "whatsapp", + const accountId = + provider === lastProvider ? entry?.lastAccountId : undefined; + const resolved = resolveOutboundTarget({ + provider, to, - allowFrom: rawAllow, + cfg, + accountId, + mode: "heartbeat", }); if (!resolved.ok) { return { provider: "none", reason: "no-target" }; } - if (rawAllow.includes("*")) return { provider, to: resolved.to }; - if (isWhatsAppGroupJid(resolved.to)) return { provider, to: resolved.to }; - const allowFrom = rawAllow - .map((val) => normalizeE164(val)) - .filter((val) => val.length > 1); - if (allowFrom.length === 0) return { provider, to: resolved.to }; - if (allowFrom.includes(resolved.to)) return { provider, to: resolved.to }; - return { provider, to: allowFrom[0], reason: "allowFrom-fallback" }; + + let reason: string | undefined; + const plugin = getProviderPlugin(provider as ProviderId); + if (plugin?.config.resolveAllowFrom) { + const explicit = resolveOutboundTarget({ + provider, + to, + cfg, + accountId, + mode: "explicit", + }); + if (explicit.ok && explicit.to !== resolved.to) { + reason = "allowFrom-fallback"; + } + } + + return reason + ? { provider, to: resolved.to, reason } + : { provider, to: resolved.to }; } diff --git a/src/infra/provider-activity.ts b/src/infra/provider-activity.ts index dff0df0c1..40230b865 100644 --- a/src/infra/provider-activity.ts +++ b/src/infra/provider-activity.ts @@ -1,4 +1,4 @@ -export type ProviderId = "discord" | "telegram" | "whatsapp"; +import type { ProviderId } from "../providers/plugins/types.js"; export type ProviderDirection = "inbound" | "outbound"; type ActivityEntry = { diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index a7ff71704..14b6e1ab7 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -1,34 +1,11 @@ import { type ClawdbotConfig, loadConfig } from "../config/config.js"; -import { - listDiscordAccountIds, - resolveDiscordAccount, -} from "../discord/accounts.js"; -import { - listIMessageAccountIds, - resolveIMessageAccount, -} from "../imessage/accounts.js"; -import { resolveMSTeamsCredentials } from "../msteams/token.js"; +import { listProviderPlugins } from "../providers/plugins/index.js"; +import type { + ProviderAccountSnapshot, + ProviderPlugin, +} from "../providers/plugins/types.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; -import { - listSignalAccountIds, - resolveSignalAccount, -} from "../signal/accounts.js"; -import { listSlackAccountIds, resolveSlackAccount } from "../slack/accounts.js"; -import { - listTelegramAccountIds, - resolveTelegramAccount, -} from "../telegram/accounts.js"; import { theme } from "../terminal/theme.js"; -import { normalizeE164 } from "../utils.js"; -import { - listWhatsAppAccountIds, - resolveWhatsAppAccount, -} from "../web/accounts.js"; -import { - getWebAuthAgeMs, - readWebSelfId, - webAuthExists, -} from "../web/session.js"; export type ProviderSummaryOptions = { colorize?: boolean; @@ -40,6 +17,120 @@ const DEFAULT_OPTIONS: Required = { includeAllowFrom: false, }; +type ProviderAccountEntry = { + accountId: string; + account: unknown; + enabled: boolean; + configured: boolean; + snapshot: ProviderAccountSnapshot; +}; + +const formatAccountLabel = (params: { accountId: string; name?: string }) => { + const base = params.accountId || DEFAULT_ACCOUNT_ID; + if (params.name?.trim()) return `${base} (${params.name.trim()})`; + return base; +}; + +const accountLine = (label: string, details: string[]) => + ` - ${label}${details.length ? ` (${details.join(", ")})` : ""}`; + +const resolveAccountEnabled = ( + plugin: ProviderPlugin, + account: unknown, + cfg: ClawdbotConfig, +): boolean => { + if (plugin.config.isEnabled) { + return plugin.config.isEnabled(account, cfg); + } + if (!account || typeof account !== "object") return true; + const enabled = (account as { enabled?: boolean }).enabled; + return enabled !== false; +}; + +const resolveAccountConfigured = async ( + plugin: ProviderPlugin, + account: unknown, + cfg: ClawdbotConfig, +): Promise => { + if (plugin.config.isConfigured) { + return await plugin.config.isConfigured(account, cfg); + } + return true; +}; + +const buildAccountSnapshot = (params: { + plugin: ProviderPlugin; + account: unknown; + cfg: ClawdbotConfig; + accountId: string; + enabled: boolean; + configured: boolean; +}): ProviderAccountSnapshot => { + const described = params.plugin.config.describeAccount + ? params.plugin.config.describeAccount(params.account, params.cfg) + : undefined; + return { + enabled: params.enabled, + configured: params.configured, + ...described, + accountId: params.accountId, + }; +}; + +const formatAllowFrom = (params: { + plugin: ProviderPlugin; + cfg: ClawdbotConfig; + accountId?: string | null; + allowFrom: Array; +}) => { + if (params.plugin.config.formatAllowFrom) { + return params.plugin.config.formatAllowFrom({ + cfg: params.cfg, + accountId: params.accountId, + allowFrom: params.allowFrom, + }); + } + return params.allowFrom.map((entry) => String(entry).trim()).filter(Boolean); +}; + +const buildAccountDetails = (params: { + entry: ProviderAccountEntry; + plugin: ProviderPlugin; + cfg: ClawdbotConfig; + includeAllowFrom: boolean; +}): string[] => { + const details: string[] = []; + const snapshot = params.entry.snapshot; + if (snapshot.enabled === false) details.push("disabled"); + if (snapshot.dmPolicy) details.push(`dm:${snapshot.dmPolicy}`); + if (snapshot.tokenSource && snapshot.tokenSource !== "none") { + details.push(`token:${snapshot.tokenSource}`); + } + if (snapshot.botTokenSource && snapshot.botTokenSource !== "none") { + details.push(`bot:${snapshot.botTokenSource}`); + } + if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") { + details.push(`app:${snapshot.appTokenSource}`); + } + if (snapshot.baseUrl) details.push(snapshot.baseUrl); + if (snapshot.port != null) details.push(`port:${snapshot.port}`); + if (snapshot.cliPath) details.push(`cli:${snapshot.cliPath}`); + if (snapshot.dbPath) details.push(`db:${snapshot.dbPath}`); + + if (params.includeAllowFrom && snapshot.allowFrom?.length) { + const formatted = formatAllowFrom({ + plugin: params.plugin, + cfg: params.cfg, + accountId: snapshot.accountId, + allowFrom: snapshot.allowFrom, + }).slice(0, 2); + if (formatted.length > 0) { + details.push(`allow:${formatted.join(",")}`); + } + } + return details; +}; + export async function buildProviderSummary( cfg?: ClawdbotConfig, options?: ProviderSummaryOptions, @@ -49,55 +140,106 @@ export async function buildProviderSummary( const resolved = { ...DEFAULT_OPTIONS, ...options }; const tint = (value: string, color?: (input: string) => string) => resolved.colorize && color ? color(value) : value; - const formatAccountLabel = (params: { accountId: string; name?: string }) => { - const base = params.accountId || DEFAULT_ACCOUNT_ID; - if (params.name?.trim()) return `${base} (${params.name.trim()})`; - return base; - }; - const accountLine = (label: string, details: string[]) => - ` - ${label}${details.length ? ` (${details.join(", ")})` : ""}`; - const webEnabled = effective.web?.enabled !== false; - if (!webEnabled) { - lines.push(tint("WhatsApp: disabled", theme.muted)); - } else { - const webLinked = await webAuthExists(); - const authAgeMs = getWebAuthAgeMs(); - const authAge = authAgeMs === null ? "" : ` auth ${formatAge(authAgeMs)}`; - const { e164 } = readWebSelfId(); - lines.push( - webLinked - ? tint( - `WhatsApp: linked${e164 ? ` ${e164}` : ""}${authAge}`, - theme.success, - ) - : tint("WhatsApp: not linked", theme.error), - ); - if (webLinked) { - for (const accountId of listWhatsAppAccountIds(effective)) { - const account = resolveWhatsAppAccount({ cfg: effective, accountId }); - const details: string[] = []; - if (!account.enabled) details.push("disabled"); - if (account.selfChatMode) details.push("self-chat"); - const dmPolicy = - account.dmPolicy ?? effective.whatsapp?.dmPolicy ?? "pairing"; - details.push(`dm:${dmPolicy}`); - const allowFrom = ( - account.allowFrom ?? - effective.whatsapp?.allowFrom ?? - [] - ) - .map(normalizeE164) - .filter(Boolean) - .slice(0, 2); - if (allowFrom.length > 0) { - details.push(`allow:${allowFrom.join(",")}`); - } + for (const plugin of listProviderPlugins()) { + const accountIds = plugin.config.listAccountIds(effective); + const defaultAccountId = + plugin.config.defaultAccountId?.(effective) ?? + accountIds[0] ?? + DEFAULT_ACCOUNT_ID; + const resolvedAccountIds = + accountIds.length > 0 ? accountIds : [defaultAccountId]; + const entries: ProviderAccountEntry[] = []; + + for (const accountId of resolvedAccountIds) { + const account = plugin.config.resolveAccount(effective, accountId); + const enabled = resolveAccountEnabled(plugin, account, effective); + const configured = await resolveAccountConfigured( + plugin, + account, + effective, + ); + const snapshot = buildAccountSnapshot({ + plugin, + account, + cfg: effective, + accountId, + enabled, + configured, + }); + entries.push({ accountId, account, enabled, configured, snapshot }); + } + + const configuredEntries = entries.filter((entry) => entry.configured); + const anyEnabled = entries.some((entry) => entry.enabled); + const fallbackEntry = + entries.find((entry) => entry.accountId === defaultAccountId) ?? + entries[0]; + const summary = plugin.status?.buildProviderSummary + ? await plugin.status.buildProviderSummary({ + account: fallbackEntry?.account ?? {}, + cfg: effective, + defaultAccountId, + snapshot: + fallbackEntry?.snapshot ?? + ({ accountId: defaultAccountId } as ProviderAccountSnapshot), + }) + : undefined; + + const summaryRecord = summary as Record | undefined; + const linked = + summaryRecord && typeof summaryRecord.linked === "boolean" + ? summaryRecord.linked + : null; + const configured = + summaryRecord && typeof summaryRecord.configured === "boolean" + ? summaryRecord.configured + : configuredEntries.length > 0; + + const status = !anyEnabled + ? "disabled" + : linked !== null + ? linked + ? "linked" + : "not linked" + : configured + ? "configured" + : "not configured"; + + const statusColor = + status === "linked" || status === "configured" + ? theme.success + : status === "not linked" + ? theme.error + : theme.muted; + const baseLabel = plugin.meta.label ?? plugin.id; + let line = `${baseLabel}: ${status}`; + + const authAgeMs = + summaryRecord && typeof summaryRecord.authAgeMs === "number" + ? summaryRecord.authAgeMs + : null; + const self = summaryRecord?.self as { e164?: string | null } | undefined; + if (self?.e164) line += ` ${self.e164}`; + if (authAgeMs != null && authAgeMs >= 0) { + line += ` auth ${formatAge(authAgeMs)}`; + } + + lines.push(tint(line, statusColor)); + + if (configuredEntries.length > 0) { + for (const entry of configuredEntries) { + const details = buildAccountDetails({ + entry, + plugin, + cfg: effective, + includeAllowFrom: resolved.includeAllowFrom, + }); lines.push( accountLine( formatAccountLabel({ - accountId: account.accountId, - name: account.name, + accountId: entry.accountId, + name: entry.snapshot.name, }), details, ), @@ -106,219 +248,6 @@ export async function buildProviderSummary( } } - const telegramEnabled = effective.telegram?.enabled !== false; - if (!telegramEnabled) { - lines.push(tint("Telegram: disabled", theme.muted)); - } else { - const accounts = listTelegramAccountIds(effective).map((accountId) => - resolveTelegramAccount({ cfg: effective, accountId }), - ); - const configuredAccounts = accounts.filter((account) => - Boolean(account.token?.trim()), - ); - const telegramConfigured = configuredAccounts.length > 0; - lines.push( - telegramConfigured - ? tint("Telegram: configured", theme.success) - : tint("Telegram: not configured", theme.muted), - ); - if (telegramConfigured) { - for (const account of configuredAccounts) { - const details: string[] = []; - if (!account.enabled) details.push("disabled"); - if (account.tokenSource && account.tokenSource !== "none") { - details.push(`token:${account.tokenSource}`); - } - lines.push( - accountLine( - formatAccountLabel({ - accountId: account.accountId, - name: account.name, - }), - details, - ), - ); - } - } - } - - const discordEnabled = effective.discord?.enabled !== false; - if (!discordEnabled) { - lines.push(tint("Discord: disabled", theme.muted)); - } else { - const accounts = listDiscordAccountIds(effective).map((accountId) => - resolveDiscordAccount({ cfg: effective, accountId }), - ); - const configuredAccounts = accounts.filter((account) => - Boolean(account.token?.trim()), - ); - const discordConfigured = configuredAccounts.length > 0; - lines.push( - discordConfigured - ? tint("Discord: configured", theme.success) - : tint("Discord: not configured", theme.muted), - ); - if (discordConfigured) { - for (const account of configuredAccounts) { - const details: string[] = []; - if (!account.enabled) details.push("disabled"); - if (account.tokenSource && account.tokenSource !== "none") { - details.push(`token:${account.tokenSource}`); - } - lines.push( - accountLine( - formatAccountLabel({ - accountId: account.accountId, - name: account.name, - }), - details, - ), - ); - } - } - } - - const slackEnabled = effective.slack?.enabled !== false; - if (!slackEnabled) { - lines.push(tint("Slack: disabled", theme.muted)); - } else { - const accounts = listSlackAccountIds(effective).map((accountId) => - resolveSlackAccount({ cfg: effective, accountId }), - ); - const configuredAccounts = accounts.filter( - (account) => - Boolean(account.botToken?.trim()) && Boolean(account.appToken?.trim()), - ); - const slackConfigured = configuredAccounts.length > 0; - lines.push( - slackConfigured - ? tint("Slack: configured", theme.success) - : tint("Slack: not configured", theme.muted), - ); - if (slackConfigured) { - for (const account of configuredAccounts) { - const details: string[] = []; - if (!account.enabled) details.push("disabled"); - if (account.botTokenSource && account.botTokenSource !== "none") { - details.push(`bot:${account.botTokenSource}`); - } - if (account.appTokenSource && account.appTokenSource !== "none") { - details.push(`app:${account.appTokenSource}`); - } - lines.push( - accountLine( - formatAccountLabel({ - accountId: account.accountId, - name: account.name, - }), - details, - ), - ); - } - } - } - - const signalEnabled = effective.signal?.enabled !== false; - if (!signalEnabled) { - lines.push(tint("Signal: disabled", theme.muted)); - } else { - const accounts = listSignalAccountIds(effective).map((accountId) => - resolveSignalAccount({ cfg: effective, accountId }), - ); - const configuredAccounts = accounts.filter((account) => account.configured); - const signalConfigured = configuredAccounts.length > 0; - lines.push( - signalConfigured - ? tint("Signal: configured", theme.success) - : tint("Signal: not configured", theme.muted), - ); - if (signalConfigured) { - for (const account of configuredAccounts) { - const details: string[] = []; - if (!account.enabled) details.push("disabled"); - if (account.baseUrl) details.push(account.baseUrl); - lines.push( - accountLine( - formatAccountLabel({ - accountId: account.accountId, - name: account.name, - }), - details, - ), - ); - } - } - } - - const imessageEnabled = effective.imessage?.enabled !== false; - if (!imessageEnabled) { - lines.push(tint("iMessage: disabled", theme.muted)); - } else { - const accounts = listIMessageAccountIds(effective).map((accountId) => - resolveIMessageAccount({ cfg: effective, accountId }), - ); - const configuredAccounts = accounts.filter((account) => - Boolean( - account.config.cliPath || - account.config.dbPath || - account.config.allowFrom || - account.config.service || - account.config.region, - ), - ); - const imessageConfigured = configuredAccounts.length > 0; - lines.push( - imessageConfigured - ? tint("iMessage: configured", theme.success) - : tint("iMessage: not configured", theme.muted), - ); - if (imessageConfigured) { - for (const account of configuredAccounts) { - const details: string[] = []; - if (!account.enabled) details.push("disabled"); - lines.push( - accountLine( - formatAccountLabel({ - accountId: account.accountId, - name: account.name, - }), - details, - ), - ); - } - } - } - - if (resolved.includeAllowFrom) { - const allowFrom = effective.whatsapp?.allowFrom?.length - ? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean) - : []; - if (allowFrom.length) { - lines.push(tint(`AllowFrom: ${allowFrom.join(", ")}`, theme.muted)); - } - } - - const msEnabled = effective.msteams?.enabled !== false; - if (!msEnabled) { - lines.push(tint("MS Teams: disabled", theme.muted)); - } else { - const configured = Boolean(resolveMSTeamsCredentials(effective.msteams)); - lines.push( - configured - ? tint("MS Teams: configured", theme.success) - : tint("MS Teams: not configured", theme.muted), - ); - if (configured && resolved.includeAllowFrom) { - const allowFrom = (effective.msteams?.allowFrom ?? []) - .map((val) => val.trim()) - .filter(Boolean) - .slice(0, 2); - if (allowFrom.length > 0) { - lines.push(accountLine("default", [`allow:${allowFrom.join(",")}`])); - } - } - } - return lines; } diff --git a/src/infra/providers-status-issues.ts b/src/infra/providers-status-issues.ts index 4f1cd13af..6c62cf326 100644 --- a/src/infra/providers-status-issues.ts +++ b/src/infra/providers-status-issues.ts @@ -1,456 +1,23 @@ -export type ProviderStatusIssue = { - provider: - | "discord" - | "telegram" - | "whatsapp" - | "slack" - | "signal" - | "imessage"; - accountId: string; - kind: "intent" | "permissions" | "config" | "auth" | "runtime"; - message: string; - fix?: string; -}; - -type DiscordIntentSummary = { - messageContent?: "enabled" | "limited" | "disabled"; -}; - -type DiscordApplicationSummary = { - intents?: DiscordIntentSummary; -}; - -type DiscordAccountStatus = { - accountId?: unknown; - enabled?: unknown; - configured?: unknown; - application?: unknown; - audit?: unknown; -}; - -type TelegramAccountStatus = { - accountId?: unknown; - enabled?: unknown; - configured?: unknown; - allowUnmentionedGroups?: unknown; - audit?: unknown; -}; - -type WhatsAppAccountStatus = { - accountId?: unknown; - enabled?: unknown; - linked?: unknown; - connected?: unknown; - running?: unknown; - reconnectAttempts?: unknown; - lastError?: unknown; -}; - -type RuntimeAccountStatus = { - accountId?: unknown; - enabled?: unknown; - configured?: unknown; - running?: unknown; - lastError?: unknown; -}; - -function asString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 - ? value.trim() - : undefined; -} - -function formatValue(value: unknown): string | undefined { - const s = asString(value); - if (s) return s; - if (value == null) return undefined; - try { - return JSON.stringify(value); - } catch { - if (typeof value === "bigint") return value.toString(); - if (typeof value === "number" || typeof value === "boolean") { - return value.toString(); - } - if (typeof value === "symbol") return value.toString(); - return Object.prototype.toString.call(value); - } -} - -function shorten(message: string, maxLen = 140): string { - const cleaned = message.replace(/\s+/g, " ").trim(); - if (cleaned.length <= maxLen) return cleaned; - return `${cleaned.slice(0, Math.max(0, maxLen - 1))}…`; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function readDiscordAccountStatus(value: unknown): DiscordAccountStatus | null { - if (!isRecord(value)) return null; - return { - accountId: value.accountId, - enabled: value.enabled, - configured: value.configured, - application: value.application, - audit: value.audit, - }; -} - -function readDiscordApplicationSummary( - value: unknown, -): DiscordApplicationSummary { - if (!isRecord(value)) return {}; - const intentsRaw = value.intents; - if (!isRecord(intentsRaw)) return {}; - return { - intents: { - messageContent: - intentsRaw.messageContent === "enabled" || - intentsRaw.messageContent === "limited" || - intentsRaw.messageContent === "disabled" - ? intentsRaw.messageContent - : undefined, - }, - }; -} - -type DiscordPermissionsAuditSummary = { - unresolvedChannels?: number; - channels?: Array<{ - channelId: string; - ok?: boolean; - missing?: string[]; - error?: string | null; - }>; -}; - -function readDiscordPermissionsAuditSummary( - value: unknown, -): DiscordPermissionsAuditSummary { - if (!isRecord(value)) return {}; - const unresolvedChannels = - typeof value.unresolvedChannels === "number" && - Number.isFinite(value.unresolvedChannels) - ? value.unresolvedChannels - : undefined; - const channelsRaw = value.channels; - const channels = Array.isArray(channelsRaw) - ? (channelsRaw - .map((entry) => { - if (!isRecord(entry)) return null; - const channelId = asString(entry.channelId); - if (!channelId) return null; - const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; - const missing = Array.isArray(entry.missing) - ? entry.missing.map((v) => asString(v)).filter(Boolean) - : undefined; - const error = asString(entry.error) ?? null; - return { - channelId, - ok, - missing: missing?.length ? missing : undefined, - error, - }; - }) - .filter(Boolean) as DiscordPermissionsAuditSummary["channels"]) - : undefined; - return { unresolvedChannels, channels }; -} - -function readTelegramAccountStatus( - value: unknown, -): TelegramAccountStatus | null { - if (!isRecord(value)) return null; - return { - accountId: value.accountId, - enabled: value.enabled, - configured: value.configured, - allowUnmentionedGroups: value.allowUnmentionedGroups, - audit: value.audit, - }; -} - -type TelegramGroupMembershipAuditSummary = { - unresolvedGroups?: number; - hasWildcardUnmentionedGroups?: boolean; - groups?: Array<{ - chatId: string; - ok?: boolean; - status?: string | null; - error?: string | null; - }>; -}; - -function readTelegramGroupMembershipAuditSummary( - value: unknown, -): TelegramGroupMembershipAuditSummary { - if (!isRecord(value)) return {}; - const unresolvedGroups = - typeof value.unresolvedGroups === "number" && - Number.isFinite(value.unresolvedGroups) - ? value.unresolvedGroups - : undefined; - const hasWildcardUnmentionedGroups = - typeof value.hasWildcardUnmentionedGroups === "boolean" - ? value.hasWildcardUnmentionedGroups - : undefined; - const groupsRaw = value.groups; - const groups = Array.isArray(groupsRaw) - ? (groupsRaw - .map((entry) => { - if (!isRecord(entry)) return null; - const chatId = asString(entry.chatId); - if (!chatId) return null; - const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; - const status = asString(entry.status) ?? null; - const error = asString(entry.error) ?? null; - return { chatId, ok, status, error }; - }) - .filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"]) - : undefined; - return { unresolvedGroups, hasWildcardUnmentionedGroups, groups }; -} - -function readWhatsAppAccountStatus( - value: unknown, -): WhatsAppAccountStatus | null { - if (!isRecord(value)) return null; - return { - accountId: value.accountId, - enabled: value.enabled, - linked: value.linked, - connected: value.connected, - running: value.running, - reconnectAttempts: value.reconnectAttempts, - lastError: value.lastError, - }; -} - -function readRuntimeAccountStatus(value: unknown): RuntimeAccountStatus | null { - if (!isRecord(value)) return null; - return { - accountId: value.accountId, - enabled: value.enabled, - configured: value.configured, - running: value.running, - lastError: value.lastError, - }; -} +import { listProviderPlugins } from "../providers/plugins/index.js"; +import type { + ProviderAccountSnapshot, + ProviderStatusIssue, +} from "../providers/plugins/types.js"; export function collectProvidersStatusIssues( payload: Record, ): ProviderStatusIssue[] { const issues: ProviderStatusIssue[] = []; + const accountsByProvider = payload.providerAccounts as + | Record + | undefined; + for (const plugin of listProviderPlugins()) { + const collect = plugin.status?.collectStatusIssues; + if (!collect) continue; + const raw = accountsByProvider?.[plugin.id]; + if (!Array.isArray(raw)) continue; - const discordAccountsRaw = payload.discordAccounts; - if (Array.isArray(discordAccountsRaw)) { - for (const entry of discordAccountsRaw) { - const account = readDiscordAccountStatus(entry); - if (!account) continue; - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - const configured = account.configured === true; - if (!enabled || !configured) continue; - - const app = readDiscordApplicationSummary(account.application); - const messageContent = app.intents?.messageContent; - if (messageContent === "disabled") { - issues.push({ - provider: "discord", - accountId, - kind: "intent", - message: - "Message Content Intent is disabled. Bot may not see normal channel messages.", - fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.", - }); - } - - const audit = readDiscordPermissionsAuditSummary(account.audit); - if (audit.unresolvedChannels && audit.unresolvedChannels > 0) { - issues.push({ - provider: "discord", - accountId, - kind: "config", - message: `Some configured guild channels are not numeric IDs (unresolvedChannels=${audit.unresolvedChannels}). Permission audit can only check numeric channel IDs.`, - fix: "Use numeric channel IDs as keys in discord.guilds.*.channels (then rerun providers status --probe).", - }); - } - for (const channel of audit.channels ?? []) { - if (channel.ok === true) continue; - const missing = channel.missing?.length - ? ` missing ${channel.missing.join(", ")}` - : ""; - const error = channel.error ? `: ${channel.error}` : ""; - issues.push({ - provider: "discord", - accountId, - kind: "permissions", - message: `Channel ${channel.channelId} permission check failed.${missing}${error}`, - fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).", - }); - } - } + issues.push(...collect(raw as ProviderAccountSnapshot[])); } - - const telegramAccountsRaw = payload.telegramAccounts; - if (Array.isArray(telegramAccountsRaw)) { - for (const entry of telegramAccountsRaw) { - const account = readTelegramAccountStatus(entry); - if (!account) continue; - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - const configured = account.configured === true; - if (!enabled || !configured) continue; - - if (account.allowUnmentionedGroups === true) { - issues.push({ - provider: "telegram", - accountId, - kind: "config", - message: - "Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.", - fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).", - }); - } - - const audit = readTelegramGroupMembershipAuditSummary(account.audit); - if (audit.hasWildcardUnmentionedGroups === true) { - issues.push({ - provider: "telegram", - accountId, - kind: "config", - message: - 'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.', - fix: "Add explicit numeric group ids under telegram.groups (or per-account groups) to enable probing.", - }); - } - if (audit.unresolvedGroups && audit.unresolvedGroups > 0) { - issues.push({ - provider: "telegram", - accountId, - kind: "config", - message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`, - fix: "Use numeric chat IDs (e.g. -100...) as keys in telegram.groups for requireMention=false groups.", - }); - } - for (const group of audit.groups ?? []) { - if (group.ok === true) continue; - const status = group.status ? ` status=${group.status}` : ""; - const err = group.error ? `: ${group.error}` : ""; - issues.push({ - provider: "telegram", - accountId, - kind: "runtime", - message: `Group ${group.chatId} not reachable by bot.${status}${err}`, - fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.", - }); - } - } - } - - const whatsappAccountsRaw = payload.whatsappAccounts; - if (Array.isArray(whatsappAccountsRaw)) { - for (const entry of whatsappAccountsRaw) { - const account = readWhatsAppAccountStatus(entry); - if (!account) continue; - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - if (!enabled) continue; - const linked = account.linked === true; - const running = account.running === true; - const connected = account.connected === true; - const reconnectAttempts = - typeof account.reconnectAttempts === "number" - ? account.reconnectAttempts - : null; - const lastError = asString(account.lastError); - - if (!linked) { - issues.push({ - provider: "whatsapp", - accountId, - kind: "auth", - message: "Not linked (no WhatsApp Web session).", - fix: "Run: clawdbot providers login (scan QR on the gateway host).", - }); - continue; - } - - if (running && !connected) { - issues.push({ - provider: "whatsapp", - accountId, - kind: "runtime", - message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`, - fix: "Run: clawdbot doctor (or restart the gateway). If it persists, relink via providers login and check logs.", - }); - } - } - } - - const slackAccountsRaw = payload.slackAccounts; - if (Array.isArray(slackAccountsRaw)) { - for (const entry of slackAccountsRaw) { - const account = readRuntimeAccountStatus(entry); - if (!account) continue; - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - const configured = account.configured === true; - if (!enabled || !configured) continue; - const lastError = formatValue(account.lastError); - if (!lastError) continue; - issues.push({ - provider: "slack", - accountId, - kind: "runtime", - message: `Provider error: ${shorten(lastError)}`, - fix: "Check gateway logs (`clawdbot logs --follow`) and re-auth/restart if needed.", - }); - } - } - - const signalAccountsRaw = payload.signalAccounts; - if (Array.isArray(signalAccountsRaw)) { - for (const entry of signalAccountsRaw) { - const account = readRuntimeAccountStatus(entry); - if (!account) continue; - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - const configured = account.configured === true; - if (!enabled || !configured) continue; - const lastError = formatValue(account.lastError); - if (!lastError) continue; - issues.push({ - provider: "signal", - accountId, - kind: "runtime", - message: `Provider error: ${shorten(lastError)}`, - fix: "Check gateway logs (`clawdbot logs --follow`) and verify signal CLI/service setup.", - }); - } - } - - const imessageAccountsRaw = payload.imessageAccounts; - if (Array.isArray(imessageAccountsRaw)) { - for (const entry of imessageAccountsRaw) { - const account = readRuntimeAccountStatus(entry); - if (!account) continue; - const accountId = asString(account.accountId) ?? "default"; - const enabled = account.enabled !== false; - const configured = account.configured === true; - if (!enabled || !configured) continue; - const lastError = formatValue(account.lastError); - if (!lastError) continue; - issues.push({ - provider: "imessage", - accountId, - kind: "runtime", - message: `Provider error: ${shorten(lastError)}`, - fix: "Check macOS permissions/TCC and gateway logs (`clawdbot logs --follow`).", - }); - } - } - return issues; } diff --git a/src/infra/system-presence.test.ts b/src/infra/system-presence.test.ts index 8f56acb6d..3a9589d5b 100644 --- a/src/infra/system-presence.test.ts +++ b/src/infra/system-presence.test.ts @@ -13,17 +13,17 @@ describe("system-presence", () => { upsertPresence(instanceIdUpper, { host: "clawdbot", - mode: "app", + mode: "ui", instanceId: instanceIdUpper, reason: "connect", }); updateSystemPresence({ - text: "Node: Peter-Mac-Studio (10.0.0.1) · app 2.0.0 · last input 5s ago · mode app · reason beacon", + text: "Node: Peter-Mac-Studio (10.0.0.1) · ui 2.0.0 · last input 5s ago · mode ui · reason beacon", instanceId: instanceIdLower, host: "Peter-Mac-Studio", ip: "10.0.0.1", - mode: "app", + mode: "ui", version: "2.0.0", lastInputSeconds: 5, reason: "beacon", diff --git a/src/logging.ts b/src/logging.ts index 75fead97a..af80b3c40 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -6,6 +6,7 @@ import { Chalk } from "chalk"; import { Logger as TsLogger } from "tslog"; import { type ClawdbotConfig, loadConfig } from "./config/config.js"; import { isVerbose } from "./globals.js"; +import { CHAT_PROVIDER_ORDER } from "./providers/registry.js"; import { defaultRuntime, type RuntimeEnv } from "./runtime.js"; // Pin to /tmp so mac Debug UI and docs match; os.tmpdir() can be a per-user @@ -429,6 +430,7 @@ const SUBSYSTEM_COLOR_OVERRIDES: Record< }; const SUBSYSTEM_PREFIXES_TO_DROP = ["gateway", "providers"] as const; const SUBSYSTEM_MAX_SEGMENTS = 2; +const PROVIDER_SUBSYSTEM_PREFIXES = new Set(CHAT_PROVIDER_ORDER); function pickSubsystemColor( color: ChalkInstance, @@ -457,7 +459,7 @@ function formatSubsystemForConsole(subsystem: string): string { parts.shift(); } if (parts.length === 0) return original; - if (parts[0] === "whatsapp" || parts[0] === "telegram") { + if (PROVIDER_SUBSYSTEM_PREFIXES.has(parts[0])) { return parts[0]; } if (parts.length > SUBSYSTEM_MAX_SEGMENTS) { diff --git a/src/pairing/pairing-labels.ts b/src/pairing/pairing-labels.ts index 67fad0cb3..99e043d51 100644 --- a/src/pairing/pairing-labels.ts +++ b/src/pairing/pairing-labels.ts @@ -1,11 +1,6 @@ +import { requirePairingAdapter } from "../providers/plugins/pairing.js"; import type { PairingProvider } from "./pairing-store.js"; -export const PROVIDER_ID_LABELS: Record = { - telegram: "telegramUserId", - discord: "discordUserId", - slack: "slackUserId", - signal: "signalNumber", - imessage: "imessageSenderId", - whatsapp: "whatsappSenderId", - msteams: "msteamsUserId", -}; +export function resolvePairingIdLabel(provider: PairingProvider): string { + return requirePairingAdapter(provider).idLabel; +} diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index e41a12ff6..59953bcbf 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -6,6 +6,8 @@ import path from "node:path"; import lockfile from "proper-lockfile"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; +import { requirePairingAdapter } from "../providers/plugins/pairing.js"; +import type { ProviderId } from "../providers/plugins/types.js"; const PAIRING_CODE_LENGTH = 8; const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @@ -22,14 +24,7 @@ const PAIRING_STORE_LOCK_OPTIONS = { stale: 30_000, } as const; -export type PairingProvider = - | "telegram" - | "signal" - | "imessage" - | "discord" - | "slack" - | "whatsapp" - | "msteams"; +export type PairingProvider = ProviderId; export type PairingRequest = { id: string; @@ -200,21 +195,21 @@ function normalizeId(value: string | number): string { } function normalizeAllowEntry(provider: PairingProvider, entry: string): string { + const adapter = requirePairingAdapter(provider); const trimmed = entry.trim(); if (!trimmed) return ""; if (trimmed === "*") return ""; - if (provider === "telegram") return trimmed.replace(/^(telegram|tg):/i, ""); - if (provider === "signal") return trimmed.replace(/^signal:/i, ""); - if (provider === "discord") return trimmed.replace(/^(discord|user):/i, ""); - if (provider === "slack") return trimmed.replace(/^(slack|user):/i, ""); - if (provider === "msteams") return trimmed.replace(/^(msteams|user):/i, ""); - return trimmed; + const normalized = adapter.normalizeAllowEntry + ? adapter.normalizeAllowEntry(trimmed) + : trimmed; + return String(normalized).trim(); } export async function readProviderAllowFromStore( provider: PairingProvider, env: NodeJS.ProcessEnv = process.env, ): Promise { + requirePairingAdapter(provider); const filePath = resolveAllowFromPath(provider, env); const { value } = await readJsonFile(filePath, { version: 1, @@ -231,6 +226,7 @@ export async function addProviderAllowFromStoreEntry(params: { entry: string | number; env?: NodeJS.ProcessEnv; }): Promise<{ changed: boolean; allowFrom: string[] }> { + requirePairingAdapter(params.provider); const env = params.env ?? process.env; const filePath = resolveAllowFromPath(params.provider, env); return await withFileLock( @@ -265,6 +261,7 @@ export async function listProviderPairingRequests( provider: PairingProvider, env: NodeJS.ProcessEnv = process.env, ): Promise { + requirePairingAdapter(provider); const filePath = resolvePairingPath(provider, env); return await withFileLock( filePath, @@ -308,6 +305,7 @@ export async function upsertProviderPairingRequest(params: { meta?: Record; env?: NodeJS.ProcessEnv; }): Promise<{ code: string; created: boolean }> { + requirePairingAdapter(params.provider); const env = params.env ?? process.env; const filePath = resolvePairingPath(params.provider, env); return await withFileLock( @@ -405,6 +403,7 @@ export async function approveProviderPairingCode(params: { code: string; env?: NodeJS.ProcessEnv; }): Promise<{ id: string; entry?: PairingRequest } | null> { + requirePairingAdapter(params.provider); const env = params.env ?? process.env; const code = params.code.trim().toUpperCase(); if (!code) return null; diff --git a/src/providers/dock.ts b/src/providers/dock.ts new file mode 100644 index 000000000..3c3f64eb7 --- /dev/null +++ b/src/providers/dock.ts @@ -0,0 +1,292 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveDiscordAccount } from "../discord/accounts.js"; +import { resolveIMessageAccount } from "../imessage/accounts.js"; +import { resolveSignalAccount } from "../signal/accounts.js"; +import { resolveSlackAccount } from "../slack/accounts.js"; +import { resolveTelegramAccount } from "../telegram/accounts.js"; +import { normalizeE164 } from "../utils.js"; +import { resolveWhatsAppAccount } from "../web/accounts.js"; +import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; +import { + resolveDiscordGroupRequireMention, + resolveIMessageGroupRequireMention, + resolveSlackGroupRequireMention, + resolveTelegramGroupRequireMention, + resolveWhatsAppGroupRequireMention, +} from "./plugins/group-mentions.js"; +import type { + ProviderCapabilities, + ProviderCommandAdapter, + ProviderElevatedAdapter, + ProviderGroupAdapter, + ProviderId, + ProviderMentionAdapter, + ProviderThreadingAdapter, +} from "./plugins/types.js"; +import { CHAT_PROVIDER_ORDER } from "./registry.js"; + +export type ProviderDock = { + id: ProviderId; + capabilities: ProviderCapabilities; + commands?: ProviderCommandAdapter; + outbound?: { + textChunkLimit?: number; + }; + streaming?: ProviderDockStreaming; + elevated?: ProviderElevatedAdapter; + config?: { + resolveAllowFrom?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + }) => Array | undefined; + formatAllowFrom?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + allowFrom: Array; + }) => string[]; + }; + groups?: ProviderGroupAdapter; + mentions?: ProviderMentionAdapter; + threading?: ProviderThreadingAdapter; +}; + +type ProviderDockStreaming = { + blockStreamingCoalesceDefaults?: { + minChars?: number; + idleMs?: number; + }; +}; + +const formatLower = (allowFrom: Array) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()); + +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// Provider docks: lightweight provider metadata/behavior for shared code paths. +// +// Rules: +// - keep this module *light* (no monitors, probes, puppeteer/web login, etc) +// - OK: config readers, allowFrom formatting, mention stripping patterns, threading defaults +// - shared code should import from here (and from `src/providers/registry.ts`), not from the plugins registry +// +// Adding a provider: +// - add a new entry to `DOCKS` +// - keep it cheap; push heavy logic into `src/providers/plugins/.ts` or provider modules +const DOCKS: Record = { + telegram: { + id: "telegram", + capabilities: { + chatTypes: ["direct", "group", "channel", "thread"], + nativeCommands: true, + blockStreaming: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + (resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^(telegram|tg):/i, "")) + .map((entry) => entry.toLowerCase()), + }, + groups: { + resolveRequireMention: resolveTelegramGroupRequireMention, + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.telegram?.replyToMode ?? "first", + }, + }, + whatsapp: { + id: "whatsapp", + capabilities: { + chatTypes: ["direct", "group"], + polls: true, + reactions: true, + media: true, + }, + commands: { + enforceOwnerForCommands: true, + skipWhenConfigEmpty: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter((entry): entry is string => Boolean(entry)) + .map((entry) => + entry === "*" ? entry : normalizeWhatsAppTarget(entry), + ) + .filter((entry): entry is string => Boolean(entry)), + }, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveGroupIntroHint: () => + "WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).", + }, + mentions: { + stripPatterns: ({ ctx }) => { + const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); + if (!selfE164) return []; + const escaped = escapeRegExp(selfE164); + return [escaped, `@${escaped}`]; + }, + }, + }, + discord: { + id: "discord", + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + reactions: true, + media: true, + nativeCommands: true, + threads: true, + }, + outbound: { textChunkLimit: 2000 }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + elevated: { + allowFromFallback: ({ cfg }) => cfg.discord?.dm?.allowFrom, + }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + ( + resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom), + }, + groups: { + resolveRequireMention: resolveDiscordGroupRequireMention, + }, + mentions: { + stripPatterns: () => ["<@!?\\d+>"], + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.discord?.replyToMode ?? "off", + }, + }, + slack: { + id: "slack", + capabilities: { + chatTypes: ["direct", "channel", "thread"], + reactions: true, + media: true, + nativeCommands: true, + threads: true, + }, + outbound: { textChunkLimit: 4000 }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + (resolveSlackAccount({ cfg, accountId }).dm?.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom), + }, + groups: { + resolveRequireMention: resolveSlackGroupRequireMention, + }, + threading: { + resolveReplyToMode: ({ cfg, accountId }) => + resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", + allowTagsWhenOff: true, + buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => { + const configuredReplyToMode = + resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off"; + const effectiveReplyToMode = context.ThreadLabel + ? "all" + : configuredReplyToMode; + return { + currentChannelId: context.To?.startsWith("channel:") + ? context.To.slice("channel:".length) + : undefined, + currentThreadTs: context.ReplyToId, + replyToMode: effectiveReplyToMode, + hasRepliedRef, + }; + }, + }, + }, + signal: { + id: "signal", + capabilities: { + chatTypes: ["direct", "group"], + reactions: true, + media: true, + }, + outbound: { textChunkLimit: 4000 }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + (resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => + entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")), + ) + .filter(Boolean), + }, + }, + imessage: { + id: "imessage", + capabilities: { + chatTypes: ["direct", "group"], + reactions: true, + media: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + (resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom.map((entry) => String(entry).trim()).filter(Boolean), + }, + groups: { + resolveRequireMention: resolveIMessageGroupRequireMention, + }, + }, + msteams: { + id: "msteams", + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + threads: true, + media: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg }) => cfg.msteams?.allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom), + }, + }, +}; + +export function listProviderDocks(): ProviderDock[] { + return CHAT_PROVIDER_ORDER.map((id) => DOCKS[id]); +} + +export function getProviderDock(id: ProviderId): ProviderDock | undefined { + return DOCKS[id]; +} diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index 516595768..0cf09fde7 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -93,10 +93,10 @@ describe("google-shared convertTools", () => { const list = asRecord(properties.list); const items = asRecord(list.items); - expect(params.patternProperties).toBeDefined(); + expect(params.patternProperties).toEqual({ "^x-": { type: "string" } }); expect(params.additionalProperties).toBe(false); expect(mode.const).toBe("fast"); - expect(options.anyOf).toBeDefined(); + expect(options.anyOf).toEqual([{ type: "string" }, { type: "number" }]); expect(items.const).toBe("item"); expect(params.required).toEqual(["mode"]); }); @@ -185,9 +185,8 @@ describe("google-shared convertMessages", () => { const contents = convertMessages(model, context); expect(contents).toHaveLength(1); - const parts = contents?.[0]?.parts ?? []; - expect(parts).toHaveLength(1); - expect(parts[0]).toMatchObject({ + expect(contents[0].role).toBe("model"); + expect(contents[0].parts?.[0]).toMatchObject({ thought: true, thoughtSignature: "sig", }); @@ -257,6 +256,8 @@ describe("google-shared convertMessages", () => { expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("user"); + expect(contents[0].parts).toHaveLength(1); + expect(contents[1].parts).toHaveLength(1); }); it("does not merge consecutive user messages for non-Gemini Google models", () => { @@ -278,6 +279,8 @@ describe("google-shared convertMessages", () => { expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("user"); + expect(contents[0].parts).toHaveLength(1); + expect(contents[1].parts).toHaveLength(1); }); it("does not merge consecutive model messages for Gemini", () => { @@ -342,6 +345,8 @@ describe("google-shared convertMessages", () => { expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); expect(contents[2].role).toBe("model"); + expect(contents[1].parts).toHaveLength(1); + expect(contents[2].parts).toHaveLength(1); }); it("handles user message after tool result without model response in between", () => { @@ -402,6 +407,7 @@ describe("google-shared convertMessages", () => { expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); expect(contents[2].role).toBe("user"); + expect(contents[3].role).toBe("user"); const toolResponsePart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionResponse" in part, @@ -479,6 +485,7 @@ describe("google-shared convertMessages", () => { expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); + expect(contents[2].role).toBe("model"); const toolCallPart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionCall" in part, diff --git a/src/providers/plugins/actions/discord.ts b/src/providers/plugins/actions/discord.ts new file mode 100644 index 000000000..8b3f8cb6b --- /dev/null +++ b/src/providers/plugins/actions/discord.ts @@ -0,0 +1,531 @@ +import { + createActionGate, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../agents/tools/common.js"; +import { handleDiscordAction } from "../../../agents/tools/discord-actions.js"; +import { listEnabledDiscordAccounts } from "../../../discord/accounts.js"; +import type { + ProviderMessageActionAdapter, + ProviderMessageActionName, +} from "../types.js"; + +const providerId = "discord"; + +export const discordMessageActions: ProviderMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listEnabledDiscordAccounts(cfg).filter( + (account) => account.tokenSource !== "none", + ); + if (accounts.length === 0) return []; + const gate = createActionGate(cfg.discord?.actions); + const actions = new Set(["send"]); + if (gate("polls")) actions.add("poll"); + if (gate("reactions")) { + actions.add("react"); + actions.add("reactions"); + } + if (gate("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (gate("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (gate("permissions")) actions.add("permissions"); + if (gate("threads")) { + actions.add("thread-create"); + actions.add("thread-list"); + actions.add("thread-reply"); + } + if (gate("search")) actions.add("search"); + if (gate("stickers")) actions.add("sticker"); + if (gate("memberInfo")) actions.add("member-info"); + if (gate("roleInfo")) actions.add("role-info"); + if (gate("reactions")) actions.add("emoji-list"); + if (gate("emojiUploads")) actions.add("emoji-upload"); + if (gate("stickerUploads")) actions.add("sticker-upload"); + if (gate("roles", false)) { + actions.add("role-add"); + actions.add("role-remove"); + } + if (gate("channelInfo")) { + actions.add("channel-info"); + actions.add("channel-list"); + } + if (gate("voiceStatus")) actions.add("voice-status"); + if (gate("events")) { + actions.add("event-list"); + actions.add("event-create"); + } + if (gate("moderation", false)) { + actions.add("timeout"); + actions.add("kick"); + actions.add("ban"); + } + return Array.from(actions); + }, + extractToolSend: ({ args }) => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action === "sendMessage") { + const to = typeof args.to === "string" ? args.to : undefined; + return to ? { to } : null; + } + if (action === "threadReply") { + const channelId = + typeof args.channelId === "string" ? args.channelId.trim() : ""; + return channelId ? { to: `channel:${channelId}` } : null; + } + return null; + }, + handleAction: async ({ action, params, cfg }) => { + const resolveChannelId = () => + readStringParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "message", { + required: true, + allowEmpty: true, + }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const replyTo = readStringParam(params, "replyTo"); + return await handleDiscordAction( + { + action: "sendMessage", + to, + content, + mediaUrl: mediaUrl ?? undefined, + replyTo: replyTo ?? undefined, + }, + cfg, + ); + } + + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const answers = + readStringArrayParam(params, "pollOption", { required: true }) ?? []; + const allowMultiselect = + typeof params.pollMulti === "boolean" ? params.pollMulti : undefined; + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + }); + return await handleDiscordAction( + { + action: "poll", + to, + question, + answers, + allowMultiselect, + durationHours: durationHours ?? undefined, + content: readStringParam(params, "message"), + }, + cfg, + ); + } + + if (action === "react") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = + typeof params.remove === "boolean" ? params.remove : undefined; + return await handleDiscordAction( + { + action: "react", + channelId: resolveChannelId(), + messageId, + emoji, + remove, + }, + cfg, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "reactions", + channelId: resolveChannelId(), + messageId, + limit, + }, + cfg, + ); + } + + if (action === "read") { + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "readMessages", + channelId: resolveChannelId(), + limit, + before: readStringParam(params, "before"), + after: readStringParam(params, "after"), + around: readStringParam(params, "around"), + }, + cfg, + ); + } + + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const content = readStringParam(params, "message", { required: true }); + return await handleDiscordAction( + { + action: "editMessage", + channelId: resolveChannelId(), + messageId, + content, + }, + cfg, + ); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + return await handleDiscordAction( + { + action: "deleteMessage", + channelId: resolveChannelId(), + messageId, + }, + cfg, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" + ? undefined + : readStringParam(params, "messageId", { required: true }); + return await handleDiscordAction( + { + action: + action === "pin" + ? "pinMessage" + : action === "unpin" + ? "unpinMessage" + : "listPins", + channelId: resolveChannelId(), + messageId, + }, + cfg, + ); + } + + if (action === "permissions") { + return await handleDiscordAction( + { + action: "permissions", + channelId: resolveChannelId(), + }, + cfg, + ); + } + + if (action === "thread-create") { + const name = readStringParam(params, "threadName", { required: true }); + const messageId = readStringParam(params, "messageId"); + const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { + integer: true, + }); + return await handleDiscordAction( + { + action: "threadCreate", + channelId: resolveChannelId(), + name, + messageId, + autoArchiveMinutes, + }, + cfg, + ); + } + + if (action === "thread-list") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const channelId = readStringParam(params, "channelId"); + const includeArchived = + typeof params.includeArchived === "boolean" + ? params.includeArchived + : undefined; + const before = readStringParam(params, "before"); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "threadList", + guildId, + channelId, + includeArchived, + before, + limit, + }, + cfg, + ); + } + + if (action === "thread-reply") { + const content = readStringParam(params, "message", { required: true }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const replyTo = readStringParam(params, "replyTo"); + return await handleDiscordAction( + { + action: "threadReply", + channelId: resolveChannelId(), + content, + mediaUrl: mediaUrl ?? undefined, + replyTo: replyTo ?? undefined, + }, + cfg, + ); + } + + if (action === "search") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const query = readStringParam(params, "query", { required: true }); + return await handleDiscordAction( + { + action: "searchMessages", + guildId, + content: query, + channelId: readStringParam(params, "channelId"), + channelIds: readStringArrayParam(params, "channelIds"), + authorId: readStringParam(params, "authorId"), + authorIds: readStringArrayParam(params, "authorIds"), + limit: readNumberParam(params, "limit", { integer: true }), + }, + cfg, + ); + } + + if (action === "sticker") { + const stickerIds = + readStringArrayParam(params, "stickerId", { + required: true, + label: "sticker-id", + }) ?? []; + return await handleDiscordAction( + { + action: "sticker", + to: readStringParam(params, "to", { required: true }), + stickerIds, + content: readStringParam(params, "message"), + }, + cfg, + ); + } + + if (action === "member-info") { + const userId = readStringParam(params, "userId", { required: true }); + const guildId = readStringParam(params, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "memberInfo", guildId, userId }, + cfg, + ); + } + + if (action === "role-info") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + return await handleDiscordAction({ action: "roleInfo", guildId }, cfg); + } + + if (action === "emoji-list") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + return await handleDiscordAction({ action: "emojiList", guildId }, cfg); + } + + if (action === "emoji-upload") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const name = readStringParam(params, "emojiName", { required: true }); + const mediaUrl = readStringParam(params, "media", { + required: true, + trim: false, + }); + const roleIds = readStringArrayParam(params, "roleIds"); + return await handleDiscordAction( + { + action: "emojiUpload", + guildId, + name, + mediaUrl, + roleIds, + }, + cfg, + ); + } + + if (action === "sticker-upload") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const name = readStringParam(params, "stickerName", { + required: true, + }); + const description = readStringParam(params, "stickerDesc", { + required: true, + }); + const tags = readStringParam(params, "stickerTags", { + required: true, + }); + const mediaUrl = readStringParam(params, "media", { + required: true, + trim: false, + }); + return await handleDiscordAction( + { + action: "stickerUpload", + guildId, + name, + description, + tags, + mediaUrl, + }, + cfg, + ); + } + + if (action === "role-add" || action === "role-remove") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { required: true }); + const roleId = readStringParam(params, "roleId", { required: true }); + return await handleDiscordAction( + { + action: action === "role-add" ? "roleAdd" : "roleRemove", + guildId, + userId, + roleId, + }, + cfg, + ); + } + + if (action === "channel-info") { + const channelId = readStringParam(params, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelInfo", channelId }, + cfg, + ); + } + + if (action === "channel-list") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + return await handleDiscordAction({ action: "channelList", guildId }, cfg); + } + + if (action === "voice-status") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { required: true }); + return await handleDiscordAction( + { action: "voiceStatus", guildId, userId }, + cfg, + ); + } + + if (action === "event-list") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + return await handleDiscordAction({ action: "eventList", guildId }, cfg); + } + + if (action === "event-create") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const name = readStringParam(params, "eventName", { required: true }); + const startTime = readStringParam(params, "startTime", { + required: true, + }); + const endTime = readStringParam(params, "endTime"); + const description = readStringParam(params, "desc"); + const channelId = readStringParam(params, "channelId"); + const location = readStringParam(params, "location"); + const entityType = readStringParam(params, "eventType"); + return await handleDiscordAction( + { + action: "eventCreate", + guildId, + name, + startTime, + endTime, + description, + channelId, + location, + entityType, + }, + cfg, + ); + } + + if (action === "timeout" || action === "kick" || action === "ban") { + const guildId = readStringParam(params, "guildId", { + required: true, + }); + const userId = readStringParam(params, "userId", { required: true }); + const durationMinutes = readNumberParam(params, "durationMin", { + integer: true, + }); + const until = readStringParam(params, "until"); + const reason = readStringParam(params, "reason"); + const deleteMessageDays = readNumberParam(params, "deleteDays", { + integer: true, + }); + const discordAction = action as "timeout" | "kick" | "ban"; + return await handleDiscordAction( + { + action: discordAction, + guildId, + userId, + durationMinutes, + until, + reason, + deleteMessageDays, + }, + cfg, + ); + } + + throw new Error( + `Action ${String(action)} is not supported for provider ${providerId}.`, + ); + }, +}; diff --git a/src/providers/plugins/actions/telegram.ts b/src/providers/plugins/actions/telegram.ts new file mode 100644 index 000000000..acc48d54f --- /dev/null +++ b/src/providers/plugins/actions/telegram.ts @@ -0,0 +1,120 @@ +import { + createActionGate, + readStringParam, +} from "../../../agents/tools/common.js"; +import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js"; +import type { ClawdbotConfig } from "../../../config/config.js"; +import { listEnabledTelegramAccounts } from "../../../telegram/accounts.js"; +import type { + ProviderMessageActionAdapter, + ProviderMessageActionName, +} from "../types.js"; + +const providerId = "telegram"; + +function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean { + const caps = new Set(); + for (const entry of cfg.telegram?.capabilities ?? []) { + const trimmed = String(entry).trim(); + if (trimmed) caps.add(trimmed.toLowerCase()); + } + const accounts = cfg.telegram?.accounts; + if (accounts && typeof accounts === "object") { + for (const account of Object.values(accounts)) { + const accountCaps = (account as { capabilities?: unknown })?.capabilities; + if (!Array.isArray(accountCaps)) continue; + for (const entry of accountCaps) { + const trimmed = String(entry).trim(); + if (trimmed) caps.add(trimmed.toLowerCase()); + } + } + } + return caps.has("inlinebuttons"); +} + +export const telegramMessageActions: ProviderMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listEnabledTelegramAccounts(cfg).filter( + (account) => account.tokenSource !== "none", + ); + if (accounts.length === 0) return []; + const gate = createActionGate(cfg.telegram?.actions); + const actions = new Set(["send"]); + if (gate("reactions")) actions.add("react"); + return Array.from(actions); + }, + supportsButtons: ({ cfg }) => hasTelegramInlineButtons(cfg), + extractToolSend: ({ args }) => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action !== "sendMessage") return null; + const to = typeof args.to === "string" ? args.to : undefined; + if (!to) return null; + const accountId = + typeof args.accountId === "string" ? args.accountId.trim() : undefined; + return { to, accountId }; + }, + handleAction: async ({ action, params, cfg, accountId }) => { + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "message", { + required: true, + allowEmpty: true, + }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const replyTo = readStringParam(params, "replyTo"); + const threadId = readStringParam(params, "threadId"); + let buttons = params.buttons; + if (!buttons) { + const buttonsJson = readStringParam(params, "buttonsJson", { + trim: false, + }); + if (buttonsJson) { + try { + buttons = JSON.parse(buttonsJson); + } catch { + throw new Error("buttons-json must be valid JSON"); + } + } + } + return await handleTelegramAction( + { + action: "sendMessage", + to, + content, + mediaUrl: mediaUrl ?? undefined, + replyToMessageId: replyTo ?? undefined, + messageThreadId: threadId ?? undefined, + accountId: accountId ?? undefined, + buttons, + }, + cfg, + ); + } + + if (action === "react") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = + typeof params.remove === "boolean" ? params.remove : undefined; + return await handleTelegramAction( + { + action: "react", + chatId: + readStringParam(params, "chatId") ?? + readStringParam(params, "to", { required: true }), + messageId, + emoji, + remove, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + throw new Error( + `Action ${action} is not supported for provider ${providerId}.`, + ); + }, +}; diff --git a/src/providers/plugins/agent-tools/whatsapp-login.ts b/src/providers/plugins/agent-tools/whatsapp-login.ts new file mode 100644 index 000000000..c08cef7cd --- /dev/null +++ b/src/providers/plugins/agent-tools/whatsapp-login.ts @@ -0,0 +1,74 @@ +import { Type } from "@sinclair/typebox"; +import type { ProviderAgentTool } from "../types.js"; + +export function createWhatsAppLoginTool(): ProviderAgentTool { + return { + label: "WhatsApp Login", + name: "whatsapp_login", + description: + "Generate a WhatsApp QR code for linking, or wait for the scan to complete.", + // NOTE: Using Type.Unsafe for action enum instead of Type.Union([Type.Literal(...)] + // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. + parameters: Type.Object({ + action: Type.Unsafe<"start" | "wait">({ + type: "string", + enum: ["start", "wait"], + }), + timeoutMs: Type.Optional(Type.Number()), + force: Type.Optional(Type.Boolean()), + }), + execute: async (_toolCallId, args) => { + const { startWebLoginWithQr, waitForWebLogin } = await import( + "../../../web/login-qr.js" + ); + const action = (args as { action?: string })?.action ?? "start"; + if (action === "wait") { + const result = await waitForWebLogin({ + timeoutMs: + typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" + ? (args as { timeoutMs?: number }).timeoutMs + : undefined, + }); + return { + content: [{ type: "text", text: result.message }], + details: { connected: result.connected }, + }; + } + + const result = await startWebLoginWithQr({ + timeoutMs: + typeof (args as { timeoutMs?: unknown }).timeoutMs === "number" + ? (args as { timeoutMs?: number }).timeoutMs + : undefined, + force: + typeof (args as { force?: unknown }).force === "boolean" + ? (args as { force?: boolean }).force + : false, + }); + + if (!result.qrDataUrl) { + return { + content: [ + { + type: "text", + text: result.message, + }, + ], + details: { qr: false }, + }; + } + + const text = [ + result.message, + "", + "Open WhatsApp → Linked Devices and scan:", + "", + `![whatsapp-qr](${result.qrDataUrl})`, + ].join("\n"); + return { + content: [{ type: "text", text }], + details: { qr: true }, + }; + }, + }; +} diff --git a/src/providers/plugins/config-helpers.ts b/src/providers/plugins/config-helpers.ts new file mode 100644 index 000000000..046611b53 --- /dev/null +++ b/src/providers/plugins/config-helpers.ts @@ -0,0 +1,102 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; + +type ProviderSection = { + accounts?: Record>; + enabled?: boolean; +}; + +export function setAccountEnabledInConfigSection(params: { + cfg: ClawdbotConfig; + sectionKey: string; + accountId: string; + enabled: boolean; + allowTopLevel?: boolean; +}): ClawdbotConfig { + const accountKey = params.accountId || DEFAULT_ACCOUNT_ID; + const base = (params.cfg as Record)[params.sectionKey] as + | ProviderSection + | undefined; + const hasAccounts = Boolean(base?.accounts); + if ( + params.allowTopLevel && + accountKey === DEFAULT_ACCOUNT_ID && + !hasAccounts + ) { + return { + ...params.cfg, + [params.sectionKey]: { + ...base, + enabled: params.enabled, + }, + } as ClawdbotConfig; + } + + const baseAccounts = (base?.accounts ?? {}) as Record< + string, + Record + >; + const existing = baseAccounts[accountKey] ?? {}; + return { + ...params.cfg, + [params.sectionKey]: { + ...base, + accounts: { + ...baseAccounts, + [accountKey]: { + ...existing, + enabled: params.enabled, + }, + }, + }, + } as ClawdbotConfig; +} + +export function deleteAccountFromConfigSection(params: { + cfg: ClawdbotConfig; + sectionKey: string; + accountId: string; + clearBaseFields?: string[]; +}): ClawdbotConfig { + const accountKey = params.accountId || DEFAULT_ACCOUNT_ID; + const base = (params.cfg as Record)[params.sectionKey] as + | ProviderSection + | undefined; + if (!base) return params.cfg; + + const baseAccounts = + base.accounts && typeof base.accounts === "object" + ? { ...base.accounts } + : undefined; + + if (accountKey !== DEFAULT_ACCOUNT_ID) { + const accounts = baseAccounts ? { ...baseAccounts } : {}; + delete accounts[accountKey]; + return { + ...params.cfg, + [params.sectionKey]: { + ...base, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + } as ClawdbotConfig; + } + + if (baseAccounts && Object.keys(baseAccounts).length > 0) { + delete baseAccounts[accountKey]; + const baseRecord = { ...(base as Record) }; + for (const field of params.clearBaseFields ?? []) { + if (field in baseRecord) baseRecord[field] = undefined; + } + return { + ...params.cfg, + [params.sectionKey]: { + ...baseRecord, + accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined, + }, + } as ClawdbotConfig; + } + + const clone = { ...params.cfg } as Record; + delete clone[params.sectionKey]; + return clone as ClawdbotConfig; +} diff --git a/src/providers/plugins/discord.ts b/src/providers/plugins/discord.ts new file mode 100644 index 000000000..73b5670cd --- /dev/null +++ b/src/providers/plugins/discord.ts @@ -0,0 +1,353 @@ +import { + listDiscordAccountIds, + type ResolvedDiscordAccount, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "../../discord/accounts.js"; +import { + auditDiscordChannelPermissions, + collectDiscordAuditChannelIds, +} from "../../discord/audit.js"; +import { probeDiscord } from "../../discord/probe.js"; +import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; +import { shouldLogVerbose } from "../../globals.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../routing/session-key.js"; +import { getChatProviderMeta } from "../registry.js"; +import { discordMessageActions } from "./actions/discord.js"; +import { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "./config-helpers.js"; +import { resolveDiscordGroupRequireMention } from "./group-mentions.js"; +import { formatPairingApproveHint } from "./helpers.js"; +import { normalizeDiscordMessagingTarget } from "./normalize-target.js"; +import { discordOnboardingAdapter } from "./onboarding/discord.js"; +import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; +import { + applyAccountNameToProviderSection, + migrateBaseNameToDefaultAccount, +} from "./setup-helpers.js"; +import { collectDiscordStatusIssues } from "./status-issues/discord.js"; +import type { ProviderPlugin } from "./types.js"; + +const meta = getChatProviderMeta("discord"); + +export const discordPlugin: ProviderPlugin = { + id: "discord", + meta: { + ...meta, + }, + onboarding: discordOnboardingAdapter, + pairing: { + idLabel: "discordUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), + notifyApproval: async ({ id }) => { + await sendMessageDiscord(`user:${id}`, PAIRING_APPROVED_MESSAGE); + }, + }, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["discord"] }, + config: { + listAccountIds: (cfg) => listDiscordAccountIds(cfg), + resolveAccount: (cfg, accountId) => + resolveDiscordAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "discord", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "discord", + accountId, + clearBaseFields: ["token", "name"], + }), + 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, + }), + resolveAllowFrom: ({ cfg, accountId }) => + ( + resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = + accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + cfg.discord?.accounts?.[resolvedAccountId], + ); + const allowFromPath = useAccountPath + ? `discord.accounts.${resolvedAccountId}.dm.` + : "discord.dm."; + return { + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + allowFromPath, + approveHint: formatPairingApproveHint("discord"), + normalizeEntry: (raw) => + raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), + }; + }, + }, + groups: { + resolveRequireMention: resolveDiscordGroupRequireMention, + }, + mentions: { + stripPatterns: () => ["<@!?\\d+>"], + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.discord?.replyToMode ?? "off", + }, + messaging: { + normalizeTarget: normalizeDiscordMessagingTarget, + }, + actions: discordMessageActions, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToProviderSection({ + cfg, + providerKey: "discord", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "DISCORD_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token) { + return "Discord requires --token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToProviderSection({ + cfg, + providerKey: "discord", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + providerKey: "discord", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + discord: { + ...next.discord, + enabled: true, + ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), + }, + }; + } + return { + ...next, + discord: { + ...next.discord, + enabled: true, + accounts: { + ...next.discord?.accounts, + [accountId]: { + ...next.discord?.accounts?.[accountId], + enabled: true, + ...(input.token ? { token: input.token } : {}), + }, + }, + }, + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: null, + textChunkLimit: 2000, + pollMaxOptions: 10, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Discord requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ to, text, accountId, deps, replyToId }) => { + const send = deps?.sendDiscord ?? sendMessageDiscord; + const result = await send(to, text, { + verbose: false, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { provider: "discord", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { + const send = deps?.sendDiscord ?? sendMessageDiscord; + const result = await send(to, text, { + verbose: false, + mediaUrl, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { provider: "discord", ...result }; + }, + sendPoll: async ({ to, poll, accountId }) => + await sendPollDiscord(to, poll, { + accountId: accountId ?? undefined, + }), + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: collectDiscordStatusIssues, + buildProviderSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + probeDiscord(account.token, timeoutMs, { includeApplication: true }), + auditAccount: async ({ account, timeoutMs, cfg }) => { + const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({ + cfg, + accountId: account.accountId, + }); + if (!channelIds.length && unresolvedChannels === 0) return undefined; + const botToken = account.token?.trim(); + if (!botToken) { + return { + ok: unresolvedChannels === 0, + checkedChannels: 0, + unresolvedChannels, + channels: [], + elapsedMs: 0, + }; + } + const audit = await auditDiscordChannelPermissions({ + token: botToken, + accountId: account.accountId, + channelIds, + timeoutMs, + }); + return { ...audit, unresolvedChannels }; + }, + buildAccountSnapshot: ({ account, runtime, probe, audit }) => { + const configured = Boolean(account.token?.trim()); + const app = + runtime?.application ?? + (probe as { application?: unknown })?.application; + const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot; + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + application: app ?? undefined, + bot: bot ?? undefined, + probe, + audit, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const token = account.token.trim(); + let discordBotLabel = ""; + try { + const probe = await probeDiscord(token, 2500, { + includeApplication: true, + }); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) discordBotLabel = ` (@${username})`; + ctx.setStatus({ + accountId: account.accountId, + bot: probe.bot, + application: probe.application, + }); + const messageContent = probe.application?.intents?.messageContent; + if (messageContent === "disabled") { + ctx.log?.warn( + `[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`, + ); + } else if (messageContent === "limited") { + ctx.log?.info( + `[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`, + ); + } + } catch (err) { + if (shouldLogVerbose()) { + ctx.log?.debug?.( + `[${account.accountId}] bot probe failed: ${String(err)}`, + ); + } + } + ctx.log?.info( + `[${account.accountId}] starting provider${discordBotLabel}`, + ); + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + const { monitorDiscordProvider } = await import("../../discord/index.js"); + return monitorDiscordProvider({ + token, + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + mediaMaxMb: account.config.mediaMaxMb, + historyLimit: account.config.historyLimit, + }); + }, + }, +}; diff --git a/src/providers/plugins/group-mentions.ts b/src/providers/plugins/group-mentions.ts new file mode 100644 index 000000000..bf08323c9 --- /dev/null +++ b/src/providers/plugins/group-mentions.ts @@ -0,0 +1,196 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveProviderGroupRequireMention } from "../../config/group-policy.js"; +import { resolveSlackAccount } from "../../slack/accounts.js"; + +type GroupMentionParams = { + cfg: ClawdbotConfig; + groupId?: string | null; + groupRoom?: string | null; + groupSpace?: string | null; + accountId?: string | null; +}; + +function normalizeDiscordSlug(value?: string | null) { + if (!value) return ""; + let text = value.trim().toLowerCase(); + if (!text) return ""; + text = text.replace(/^[@#]+/, ""); + text = text.replace(/[\s_]+/g, "-"); + text = text.replace(/[^a-z0-9-]+/g, "-"); + text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); + return text; +} + +function normalizeSlackSlug(raw?: string | null) { + const trimmed = raw?.trim().toLowerCase() ?? ""; + if (!trimmed) return ""; + const dashed = trimmed.replace(/\s+/g, "-"); + const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-"); + return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); +} + +function parseTelegramGroupId(value?: string | null) { + const raw = value?.trim() ?? ""; + if (!raw) return { chatId: undefined, topicId: undefined }; + const parts = raw.split(":").filter(Boolean); + if ( + parts.length >= 3 && + parts[1] === "topic" && + /^-?\d+$/.test(parts[0]) && + /^\d+$/.test(parts[2]) + ) { + return { chatId: parts[0], topicId: parts[2] }; + } + if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) { + return { chatId: parts[0], topicId: parts[1] }; + } + return { chatId: raw, topicId: undefined }; +} + +function resolveTelegramRequireMention(params: { + cfg: ClawdbotConfig; + chatId?: string; + topicId?: string; +}): boolean | undefined { + const { cfg, chatId, topicId } = params; + if (!chatId) return undefined; + const groupConfig = cfg.telegram?.groups?.[chatId]; + const groupDefault = cfg.telegram?.groups?.["*"]; + const topicConfig = + topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined; + const defaultTopicConfig = + topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined; + if (typeof topicConfig?.requireMention === "boolean") { + return topicConfig.requireMention; + } + if (typeof defaultTopicConfig?.requireMention === "boolean") { + return defaultTopicConfig.requireMention; + } + if (typeof groupConfig?.requireMention === "boolean") { + return groupConfig.requireMention; + } + if (typeof groupDefault?.requireMention === "boolean") { + return groupDefault.requireMention; + } + return undefined; +} + +function resolveDiscordGuildEntry( + guilds: NonNullable["guilds"], + groupSpace?: string | null, +) { + if (!guilds || Object.keys(guilds).length === 0) return null; + const space = groupSpace?.trim() ?? ""; + if (space && guilds[space]) return guilds[space]; + const normalized = normalizeDiscordSlug(space); + if (normalized && guilds[normalized]) return guilds[normalized]; + if (normalized) { + const match = Object.values(guilds).find( + (entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized, + ); + if (match) return match; + } + return guilds["*"] ?? null; +} + +export function resolveTelegramGroupRequireMention( + params: GroupMentionParams, +): boolean | undefined { + const { chatId, topicId } = parseTelegramGroupId(params.groupId); + const requireMention = resolveTelegramRequireMention({ + cfg: params.cfg, + chatId, + topicId, + }); + if (typeof requireMention === "boolean") return requireMention; + return resolveProviderGroupRequireMention({ + cfg: params.cfg, + provider: "telegram", + groupId: chatId ?? params.groupId, + accountId: params.accountId, + }); +} + +export function resolveWhatsAppGroupRequireMention( + params: GroupMentionParams, +): boolean { + return resolveProviderGroupRequireMention({ + cfg: params.cfg, + provider: "whatsapp", + groupId: params.groupId, + accountId: params.accountId, + }); +} + +export function resolveIMessageGroupRequireMention( + params: GroupMentionParams, +): boolean { + return resolveProviderGroupRequireMention({ + cfg: params.cfg, + provider: "imessage", + groupId: params.groupId, + accountId: params.accountId, + }); +} + +export function resolveDiscordGroupRequireMention( + params: GroupMentionParams, +): boolean { + const guildEntry = resolveDiscordGuildEntry( + params.cfg.discord?.guilds, + params.groupSpace, + ); + const channelEntries = guildEntry?.channels; + if (channelEntries && Object.keys(channelEntries).length > 0) { + const channelSlug = normalizeDiscordSlug(params.groupRoom); + const entry = + (params.groupId ? channelEntries[params.groupId] : undefined) ?? + (channelSlug + ? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`]) + : undefined) ?? + (params.groupRoom + ? channelEntries[normalizeDiscordSlug(params.groupRoom)] + : undefined); + if (entry && typeof entry.requireMention === "boolean") { + return entry.requireMention; + } + } + if (typeof guildEntry?.requireMention === "boolean") { + return guildEntry.requireMention; + } + return true; +} + +export function resolveSlackGroupRequireMention( + params: GroupMentionParams, +): boolean { + const account = resolveSlackAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const channels = account.channels ?? {}; + const keys = Object.keys(channels); + if (keys.length === 0) return true; + const channelId = params.groupId?.trim(); + const channelName = params.groupRoom?.replace(/^#/, ""); + const normalizedName = normalizeSlackSlug(channelName); + const candidates = [ + channelId ?? "", + channelName ? `#${channelName}` : "", + channelName ?? "", + normalizedName, + ].filter(Boolean); + let matched: { requireMention?: boolean } | undefined; + for (const candidate of candidates) { + if (candidate && channels[candidate]) { + matched = channels[candidate]; + break; + } + } + const fallback = channels["*"]; + const resolved = matched ?? fallback; + if (typeof resolved?.requireMention === "boolean") { + return resolved.requireMention; + } + return true; +} diff --git a/src/providers/plugins/helpers.ts b/src/providers/plugins/helpers.ts new file mode 100644 index 000000000..6a2cec6aa --- /dev/null +++ b/src/providers/plugins/helpers.ts @@ -0,0 +1,22 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import type { ProviderPlugin } from "./types.js"; + +// Provider docking helper: use this when selecting the default account for a plugin. +export function resolveProviderDefaultAccountId(params: { + plugin: ProviderPlugin; + cfg: ClawdbotConfig; + accountIds?: string[]; +}): string { + const accountIds = + params.accountIds ?? params.plugin.config.listAccountIds(params.cfg); + return ( + params.plugin.config.defaultAccountId?.(params.cfg) ?? + accountIds[0] ?? + DEFAULT_ACCOUNT_ID + ); +} + +export function formatPairingApproveHint(providerId: string): string { + return `Approve via: clawdbot pairing list ${providerId} / clawdbot pairing approve ${providerId} `; +} diff --git a/src/providers/plugins/imessage.ts b/src/providers/plugins/imessage.ts new file mode 100644 index 000000000..1fc620907 --- /dev/null +++ b/src/providers/plugins/imessage.ts @@ -0,0 +1,288 @@ +import { chunkText } from "../../auto-reply/chunk.js"; +import { + listIMessageAccountIds, + type ResolvedIMessageAccount, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "../../imessage/accounts.js"; +import { probeIMessage } from "../../imessage/probe.js"; +import { sendMessageIMessage } from "../../imessage/send.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../routing/session-key.js"; +import { getChatProviderMeta } from "../registry.js"; +import { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "./config-helpers.js"; +import { resolveIMessageGroupRequireMention } from "./group-mentions.js"; +import { formatPairingApproveHint } from "./helpers.js"; +import { resolveProviderMediaMaxBytes } from "./media-limits.js"; +import { imessageOnboardingAdapter } from "./onboarding/imessage.js"; +import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; +import { + applyAccountNameToProviderSection, + migrateBaseNameToDefaultAccount, +} from "./setup-helpers.js"; +import type { ProviderPlugin } from "./types.js"; + +const meta = getChatProviderMeta("imessage"); + +export const imessagePlugin: ProviderPlugin = { + id: "imessage", + meta: { + ...meta, + showConfigured: false, + }, + onboarding: imessageOnboardingAdapter, + pairing: { + idLabel: "imessageSenderId", + notifyApproval: async ({ id }) => { + await sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + reload: { configPrefixes: ["imessage"] }, + 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 }) => + (resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom.map((entry) => String(entry).trim()).filter(Boolean), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = + accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + cfg.imessage?.accounts?.[resolvedAccountId], + ); + const basePath = useAccountPath + ? `imessage.accounts.${resolvedAccountId}.` + : "imessage."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("imessage"), + }; + }, + }, + groups: { + resolveRequireMention: resolveIMessageGroupRequireMention, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToProviderSection({ + cfg, + providerKey: "imessage", + accountId, + name, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToProviderSection({ + cfg, + providerKey: "imessage", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + providerKey: "imessage", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + imessage: { + ...next.imessage, + enabled: true, + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.dbPath ? { dbPath: input.dbPath } : {}), + ...(input.service ? { service: input.service } : {}), + ...(input.region ? { region: input.region } : {}), + }, + }; + } + return { + ...next, + imessage: { + ...next.imessage, + enabled: true, + accounts: { + ...next.imessage?.accounts, + [accountId]: { + ...next.imessage?.accounts?.[accountId], + enabled: true, + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.dbPath ? { dbPath: input.dbPath } : {}), + ...(input.service ? { service: input.service } : {}), + ...(input.region ? { region: input.region } : {}), + }, + }, + }, + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: chunkText, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to iMessage requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = deps?.sendIMessage ?? sendMessageIMessage; + const maxBytes = resolveProviderMediaMaxBytes({ + cfg, + resolveProviderLimitMb: ({ cfg, accountId }) => + cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.imessage?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + maxBytes, + accountId: accountId ?? undefined, + }); + return { provider: "imessage", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { + const send = deps?.sendIMessage ?? sendMessageIMessage; + const maxBytes = resolveProviderMediaMaxBytes({ + cfg, + resolveProviderLimitMb: ({ cfg, accountId }) => + cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.imessage?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + }); + return { provider: "imessage", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + cliPath: null, + dbPath: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = + typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) return []; + return [ + { + provider: "imessage", + accountId: account.accountId, + kind: "runtime", + message: `Provider error: ${lastError}`, + }, + ]; + }), + buildProviderSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + cliPath: snapshot.cliPath ?? null, + dbPath: snapshot.dbPath ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ timeoutMs }) => probeIMessage(timeoutMs), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + cliPath: runtime?.cliPath ?? account.config.cliPath ?? null, + dbPath: runtime?.dbPath ?? account.config.dbPath ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + resolveAccountState: ({ enabled }) => (enabled ? "enabled" : "disabled"), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const cliPath = account.config.cliPath?.trim() || "imsg"; + const dbPath = account.config.dbPath?.trim(); + ctx.setStatus({ + accountId: account.accountId, + cliPath, + dbPath: dbPath ?? null, + }); + ctx.log?.info( + `[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, + ); + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + const { monitorIMessageProvider } = await import( + "../../imessage/index.js" + ); + return monitorIMessageProvider({ + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, + }, +}; diff --git a/src/providers/plugins/index.test.ts b/src/providers/plugins/index.test.ts new file mode 100644 index 000000000..2042a9df7 --- /dev/null +++ b/src/providers/plugins/index.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { PROVIDER_IDS } from "../registry.js"; +import { listProviderPlugins } from "./index.js"; + +describe("provider plugin registry", () => { + it("stays in sync with provider ids", () => { + const pluginIds = listProviderPlugins() + .map((plugin) => plugin.id) + .slice() + .sort(); + const providerIds = [...PROVIDER_IDS].slice().sort(); + expect(pluginIds).toEqual(providerIds); + }); +}); diff --git a/src/providers/plugins/index.ts b/src/providers/plugins/index.ts new file mode 100644 index 000000000..573248134 --- /dev/null +++ b/src/providers/plugins/index.ts @@ -0,0 +1,67 @@ +import { + CHAT_PROVIDER_ORDER, + type ChatProviderId, + normalizeChatProviderId, +} from "../registry.js"; +import { discordPlugin } from "./discord.js"; +import { imessagePlugin } from "./imessage.js"; +import { msteamsPlugin } from "./msteams.js"; +import { signalPlugin } from "./signal.js"; +import { slackPlugin } from "./slack.js"; +import { telegramPlugin } from "./telegram.js"; +import type { ProviderId, ProviderPlugin } from "./types.js"; +import { whatsappPlugin } from "./whatsapp.js"; + +// Provider plugins registry (runtime). +// +// This module is intentionally "heavy" (plugins may import provider monitors, web login, etc). +// Shared code paths (reply flow, command auth, sandbox explain) should depend on `src/providers/dock.ts` +// instead, and only call `getProviderPlugin()` at execution boundaries. +// +// Adding a provider: +// - add `Plugin` import + entry in `resolveProviders()` +// - add an entry to `src/providers/dock.ts` for shared behavior (capabilities, allowFrom, threading, …) +// - add ids/aliases in `src/providers/registry.ts` +function resolveProviders(): ProviderPlugin[] { + return [ + telegramPlugin, + whatsappPlugin, + discordPlugin, + slackPlugin, + signalPlugin, + imessagePlugin, + msteamsPlugin, + ]; +} + +export function listProviderPlugins(): ProviderPlugin[] { + return resolveProviders().sort((a, b) => { + const indexA = CHAT_PROVIDER_ORDER.indexOf(a.id as ChatProviderId); + const indexB = CHAT_PROVIDER_ORDER.indexOf(b.id as ChatProviderId); + const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); + const orderB = b.meta.order ?? (indexB === -1 ? 999 : indexB); + if (orderA !== orderB) return orderA - orderB; + return a.id.localeCompare(b.id); + }); +} + +export function getProviderPlugin(id: ProviderId): ProviderPlugin | undefined { + return resolveProviders().find((plugin) => plugin.id === id); +} + +export function normalizeProviderId(raw?: string | null): ProviderId | null { + // Provider docking: keep input normalization centralized in src/providers/registry.ts + // so CLI/API/protocol can rely on stable aliases without plugin init side effects. + return normalizeChatProviderId(raw); +} + +export { + discordPlugin, + imessagePlugin, + msteamsPlugin, + signalPlugin, + slackPlugin, + telegramPlugin, + whatsappPlugin, +}; +export type { ProviderId, ProviderPlugin } from "./types.js"; diff --git a/src/providers/plugins/load.ts b/src/providers/plugins/load.ts new file mode 100644 index 000000000..cb06600df --- /dev/null +++ b/src/providers/plugins/load.ts @@ -0,0 +1,31 @@ +import type { ProviderId, ProviderPlugin } from "./types.js"; + +type PluginLoader = () => Promise; + +// Provider docking: load *one* plugin on-demand. +// +// This avoids importing `src/providers/plugins/index.ts` (intentionally heavy) +// from shared flows like outbound delivery / followup routing. +const LOADERS: Record = { + telegram: async () => (await import("./telegram.js")).telegramPlugin, + whatsapp: async () => (await import("./whatsapp.js")).whatsappPlugin, + discord: async () => (await import("./discord.js")).discordPlugin, + slack: async () => (await import("./slack.js")).slackPlugin, + signal: async () => (await import("./signal.js")).signalPlugin, + imessage: async () => (await import("./imessage.js")).imessagePlugin, + msteams: async () => (await import("./msteams.js")).msteamsPlugin, +}; + +const cache = new Map(); + +export async function loadProviderPlugin( + id: ProviderId, +): Promise { + const cached = cache.get(id); + if (cached) return cached; + const loader = LOADERS[id]; + if (!loader) return undefined; + const plugin = await loader(); + cache.set(id, plugin); + return plugin; +} diff --git a/src/providers/plugins/media-limits.ts b/src/providers/plugins/media-limits.ts new file mode 100644 index 000000000..c4575d326 --- /dev/null +++ b/src/providers/plugins/media-limits.ts @@ -0,0 +1,26 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; + +const MB = 1024 * 1024; + +export function resolveProviderMediaMaxBytes(params: { + cfg: ClawdbotConfig; + // Provider-specific config lives under different keys; keep this helper generic + // so shared plugin helpers don't need provider-id branching. + resolveProviderLimitMb: (params: { + cfg: ClawdbotConfig; + accountId: string; + }) => number | undefined; + accountId?: string | null; +}): number | undefined { + const accountId = normalizeAccountId(params.accountId); + const providerLimit = params.resolveProviderLimitMb({ + cfg: params.cfg, + accountId, + }); + if (providerLimit) return providerLimit * MB; + if (params.cfg.agents?.defaults?.mediaMaxMb) { + return params.cfg.agents.defaults.mediaMaxMb * MB; + } + return undefined; +} diff --git a/src/providers/plugins/message-actions.ts b/src/providers/plugins/message-actions.ts new file mode 100644 index 000000000..01b5433b7 --- /dev/null +++ b/src/providers/plugins/message-actions.ts @@ -0,0 +1,41 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { getProviderPlugin, listProviderPlugins } from "./index.js"; +import type { + ProviderMessageActionContext, + ProviderMessageActionName, +} from "./types.js"; + +export function listProviderMessageActions( + cfg: ClawdbotConfig, +): ProviderMessageActionName[] { + const actions = new Set(["send"]); + for (const plugin of listProviderPlugins()) { + const list = plugin.actions?.listActions?.({ cfg }); + if (!list) continue; + for (const action of list) actions.add(action); + } + return Array.from(actions); +} + +export function supportsProviderMessageButtons(cfg: ClawdbotConfig): boolean { + for (const plugin of listProviderPlugins()) { + if (plugin.actions?.supportsButtons?.({ cfg })) return true; + } + return false; +} + +export async function dispatchProviderMessageAction( + ctx: ProviderMessageActionContext, +): Promise | null> { + const plugin = getProviderPlugin(ctx.provider); + if (!plugin?.actions?.handleAction) return null; + if ( + plugin.actions.supportsAction && + !plugin.actions.supportsAction({ action: ctx.action }) + ) { + return null; + } + return await plugin.actions.handleAction(ctx); +} diff --git a/src/providers/plugins/msteams.ts b/src/providers/plugins/msteams.ts new file mode 100644 index 000000000..56e74eb8e --- /dev/null +++ b/src/providers/plugins/msteams.ts @@ -0,0 +1,200 @@ +import { chunkMarkdownText } from "../../auto-reply/chunk.js"; +import { createMSTeamsPollStoreFs } from "../../msteams/polls.js"; +import { sendMessageMSTeams, sendPollMSTeams } from "../../msteams/send.js"; +import { resolveMSTeamsCredentials } from "../../msteams/token.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import { msteamsOnboardingAdapter } from "./onboarding/msteams.js"; +import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; +import type { ProviderMessageActionName, ProviderPlugin } from "./types.js"; + +type ResolvedMSTeamsAccount = { + accountId: string; + enabled: boolean; + configured: boolean; +}; + +const meta = { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams (Bot)", + docsPath: "/msteams", + docsLabel: "msteams", + blurb: "bot via Microsoft Teams.", +} as const; + +export const msteamsPlugin: ProviderPlugin = { + id: "msteams", + meta: { + ...meta, + }, + onboarding: msteamsOnboardingAdapter, + pairing: { + idLabel: "msteamsUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""), + notifyApproval: async ({ cfg, id }) => { + await sendMessageMSTeams({ + cfg, + to: id, + text: PAIRING_APPROVED_MESSAGE, + }); + }, + }, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + threads: true, + media: true, + }, + reload: { configPrefixes: ["msteams"] }, + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg) => ({ + accountId: DEFAULT_ACCOUNT_ID, + enabled: cfg.msteams?.enabled !== false, + configured: Boolean(resolveMSTeamsCredentials(cfg.msteams)), + }), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => ({ + ...cfg, + msteams: { + ...cfg.msteams, + enabled, + }, + }), + deleteAccount: ({ cfg }) => { + const next = { ...cfg } as Record; + delete next.msteams; + return next as typeof cfg; + }, + isConfigured: (_account, cfg) => + Boolean(resolveMSTeamsCredentials(cfg.msteams)), + describeAccount: (account) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg }) => cfg.msteams?.allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()), + }, + setup: { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + msteams: { + ...cfg.msteams, + enabled: true, + }, + }), + }, + actions: { + listActions: ({ cfg }) => { + const enabled = + cfg.msteams?.enabled !== false && + Boolean(resolveMSTeamsCredentials(cfg.msteams)); + if (!enabled) return []; + return ["poll"] satisfies ProviderMessageActionName[]; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: chunkMarkdownText, + textChunkLimit: 4000, + pollMaxOptions: 12, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to MS Teams requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ cfg, to, text, deps }) => { + const send = + deps?.sendMSTeams ?? + ((to, text) => sendMessageMSTeams({ cfg, to, text })); + const result = await send(to, text); + return { provider: "msteams", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => { + const send = + deps?.sendMSTeams ?? + ((to, text, opts) => + sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl })); + const result = await send(to, text, { mediaUrl }); + return { provider: "msteams", ...result }; + }, + sendPoll: async ({ cfg, to, poll }) => { + const maxSelections = poll.maxSelections ?? 1; + const result = await sendPollMSTeams({ + cfg, + to, + question: poll.question, + options: poll.options, + maxSelections, + }); + const pollStore = createMSTeamsPollStoreFs(); + await pollStore.createPoll({ + id: result.pollId, + question: poll.question, + options: poll.options, + maxSelections, + createdAt: new Date().toISOString(), + conversationId: result.conversationId, + messageId: result.messageId, + votes: {}, + }); + return result; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + port: null, + }, + buildProviderSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + port: snapshot.port ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + port: runtime?.port ?? null, + }), + }, + gateway: { + startAccount: async (ctx) => { + const { monitorMSTeamsProvider } = await import("../../msteams/index.js"); + const port = ctx.cfg.msteams?.webhook?.port ?? 3978; + ctx.setStatus({ accountId: ctx.accountId, port }); + ctx.log?.info(`starting provider (port ${port})`); + return monitorMSTeamsProvider({ + cfg: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, + }, +}; diff --git a/src/providers/plugins/normalize-target.ts b/src/providers/plugins/normalize-target.ts new file mode 100644 index 000000000..d9a73ebe6 --- /dev/null +++ b/src/providers/plugins/normalize-target.ts @@ -0,0 +1,119 @@ +import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; + +export function normalizeSlackMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i); + if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase(); + if (trimmed.startsWith("user:")) { + const id = trimmed.slice(5).trim(); + return id ? `user:${id}`.toLowerCase() : undefined; + } + if (trimmed.startsWith("channel:")) { + const id = trimmed.slice(8).trim(); + return id ? `channel:${id}`.toLowerCase() : undefined; + } + if (trimmed.startsWith("group:")) { + const id = trimmed.slice(6).trim(); + return id ? `channel:${id}`.toLowerCase() : undefined; + } + if (trimmed.startsWith("slack:")) { + const id = trimmed.slice(6).trim(); + return id ? `user:${id}`.toLowerCase() : undefined; + } + if (trimmed.startsWith("@")) { + const id = trimmed.slice(1).trim(); + return id ? `user:${id}`.toLowerCase() : undefined; + } + if (trimmed.startsWith("#")) { + const id = trimmed.slice(1).trim(); + return id ? `channel:${id}`.toLowerCase() : undefined; + } + return `channel:${trimmed}`.toLowerCase(); +} + +export function normalizeDiscordMessagingTarget( + raw: string, +): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + const mentionMatch = trimmed.match(/^<@!?(\d+)>$/); + if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase(); + if (trimmed.startsWith("user:")) { + const id = trimmed.slice(5).trim(); + return id ? `user:${id}`.toLowerCase() : undefined; + } + if (trimmed.startsWith("channel:")) { + const id = trimmed.slice(8).trim(); + return id ? `channel:${id}`.toLowerCase() : undefined; + } + if (trimmed.startsWith("group:")) { + const id = trimmed.slice(6).trim(); + return id ? `channel:${id}`.toLowerCase() : undefined; + } + if (trimmed.startsWith("discord:")) { + const id = trimmed.slice(8).trim(); + return id ? `user:${id}`.toLowerCase() : undefined; + } + if (trimmed.startsWith("@")) { + const id = trimmed.slice(1).trim(); + return id ? `user:${id}`.toLowerCase() : undefined; + } + return `channel:${trimmed}`.toLowerCase(); +} + +export function normalizeTelegramMessagingTarget( + raw: string, +): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + let normalized = trimmed; + if (normalized.startsWith("telegram:")) { + normalized = normalized.slice("telegram:".length).trim(); + } else if (normalized.startsWith("tg:")) { + normalized = normalized.slice("tg:".length).trim(); + } else if (normalized.startsWith("group:")) { + normalized = normalized.slice("group:".length).trim(); + } + if (!normalized) return undefined; + const tmeMatch = + /^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ?? + /^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized); + if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`; + if (!normalized) return undefined; + return `telegram:${normalized}`.toLowerCase(); +} + +export function normalizeSignalMessagingTarget( + raw: string, +): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + let normalized = trimmed; + if (normalized.toLowerCase().startsWith("signal:")) { + normalized = normalized.slice("signal:".length).trim(); + } + if (!normalized) return undefined; + const lower = normalized.toLowerCase(); + if (lower.startsWith("group:")) { + const id = normalized.slice("group:".length).trim(); + return id ? `group:${id}`.toLowerCase() : undefined; + } + if (lower.startsWith("username:")) { + const id = normalized.slice("username:".length).trim(); + return id ? `username:${id}`.toLowerCase() : undefined; + } + if (lower.startsWith("u:")) { + const id = normalized.slice("u:".length).trim(); + return id ? `username:${id}`.toLowerCase() : undefined; + } + return normalized.toLowerCase(); +} + +export function normalizeWhatsAppMessagingTarget( + raw: string, +): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + return normalizeWhatsAppTarget(trimmed) ?? undefined; +} diff --git a/src/providers/plugins/onboarding-types.ts b/src/providers/plugins/onboarding-types.ts new file mode 100644 index 000000000..af0a87599 --- /dev/null +++ b/src/providers/plugins/onboarding-types.ts @@ -0,0 +1,89 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import type { DmPolicy } from "../../config/types.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import type { ChatProviderId } from "../registry.js"; + +export type SetupProvidersOptions = { + allowDisable?: boolean; + allowSignalInstall?: boolean; + onSelection?: (selection: ChatProviderId[]) => void; + accountIds?: Partial>; + onAccountId?: (provider: ChatProviderId, accountId: string) => void; + promptAccountIds?: boolean; + whatsappAccountId?: string; + promptWhatsAppAccountId?: boolean; + onWhatsAppAccountId?: (accountId: string) => void; + forceAllowFromProviders?: ChatProviderId[]; + skipDmPolicyPrompt?: boolean; + skipConfirm?: boolean; + quickstartDefaults?: boolean; + initialSelection?: ChatProviderId[]; +}; + +export type PromptAccountIdParams = { + cfg: ClawdbotConfig; + prompter: WizardPrompter; + label: string; + currentId?: string; + listAccountIds: (cfg: ClawdbotConfig) => string[]; + defaultAccountId: string; +}; + +export type PromptAccountId = ( + params: PromptAccountIdParams, +) => Promise; + +export type ProviderOnboardingStatus = { + provider: ChatProviderId; + configured: boolean; + statusLines: string[]; + selectionHint?: string; + quickstartScore?: number; +}; + +export type ProviderOnboardingStatusContext = { + cfg: ClawdbotConfig; + options?: SetupProvidersOptions; + accountOverrides: Partial>; +}; + +export type ProviderOnboardingConfigureContext = { + cfg: ClawdbotConfig; + runtime: RuntimeEnv; + prompter: WizardPrompter; + options?: SetupProvidersOptions; + accountOverrides: Partial>; + shouldPromptAccountIds: boolean; + forceAllowFrom: boolean; +}; + +export type ProviderOnboardingResult = { + cfg: ClawdbotConfig; + accountId?: string; +}; + +export type ProviderOnboardingDmPolicy = { + label: string; + provider: ChatProviderId; + policyKey: string; + allowFromKey: string; + getCurrent: (cfg: ClawdbotConfig) => DmPolicy; + setPolicy: (cfg: ClawdbotConfig, policy: DmPolicy) => ClawdbotConfig; +}; + +export type ProviderOnboardingAdapter = { + provider: ChatProviderId; + getStatus: ( + ctx: ProviderOnboardingStatusContext, + ) => Promise; + configure: ( + ctx: ProviderOnboardingConfigureContext, + ) => Promise; + dmPolicy?: ProviderOnboardingDmPolicy; + onAccountRecorded?: ( + accountId: string, + options?: SetupProvidersOptions, + ) => void; + disable?: (cfg: ClawdbotConfig) => ClawdbotConfig; +}; diff --git a/src/providers/plugins/onboarding/discord.ts b/src/providers/plugins/onboarding/discord.ts new file mode 100644 index 000000000..96f3ef0aa --- /dev/null +++ b/src/providers/plugins/onboarding/discord.ts @@ -0,0 +1,194 @@ +import type { ClawdbotConfig } from "../../../config/config.js"; +import type { DmPolicy } from "../../../config/types.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, +} from "../../../discord/accounts.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../../routing/session-key.js"; +import { formatDocsLink } from "../../../terminal/links.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; +import type { + ProviderOnboardingAdapter, + ProviderOnboardingDmPolicy, +} from "../onboarding-types.js"; +import { addWildcardAllowFrom, promptAccountId } from "./helpers.js"; + +const provider = "discord" as const; + +function setDiscordDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.discord?.dm?.allowFrom) + : undefined; + return { + ...cfg, + discord: { + ...cfg.discord, + dm: { + ...cfg.discord?.dm, + enabled: cfg.discord?.dm?.enabled ?? true, + policy: dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Discord Developer Portal → Applications → New Application", + "2) Bot → Add Bot → Reset Token → copy token", + "3) OAuth2 → URL Generator → scope 'bot' → invite to your server", + "Tip: enable Message Content Intent if you need message text.", + `Docs: ${formatDocsLink("/discord", "discord")}`, + ].join("\n"), + "Discord bot token", + ); +} + +const dmPolicy: ProviderOnboardingDmPolicy = { + label: "Discord", + provider, + policyKey: "discord.dm.policy", + allowFromKey: "discord.dm.allowFrom", + getCurrent: (cfg) => cfg.discord?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => setDiscordDmPolicy(cfg, policy), +}; + +export const discordOnboardingAdapter: ProviderOnboardingAdapter = { + provider, + getStatus: async ({ cfg }) => { + const configured = listDiscordAccountIds(cfg).some((accountId) => + Boolean(resolveDiscordAccount({ cfg, accountId }).token), + ); + return { + provider, + configured, + statusLines: [`Discord: ${configured ? "configured" : "needs token"}`], + selectionHint: configured ? "configured" : "needs token", + quickstartScore: configured ? 2 : 1, + }; + }, + configure: async ({ + cfg, + prompter, + accountOverrides, + shouldPromptAccountIds, + }) => { + const discordOverride = accountOverrides.discord?.trim(); + const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg); + let discordAccountId = discordOverride + ? normalizeAccountId(discordOverride) + : defaultDiscordAccountId; + if (shouldPromptAccountIds && !discordOverride) { + discordAccountId = await promptAccountId({ + cfg, + prompter, + label: "Discord", + currentId: discordAccountId, + listAccountIds: listDiscordAccountIds, + defaultAccountId: defaultDiscordAccountId, + }); + } + + let next = cfg; + const resolvedAccount = resolveDiscordAccount({ + cfg: next, + accountId: discordAccountId, + }); + const accountConfigured = Boolean(resolvedAccount.token); + const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID; + const canUseEnv = + allowEnv && Boolean(process.env.DISCORD_BOT_TOKEN?.trim()); + const hasConfigToken = Boolean(resolvedAccount.config.token); + + let token: string | null = null; + if (!accountConfigured) { + await noteDiscordTokenHelp(prompter); + } + if (canUseEnv && !resolvedAccount.config.token) { + const keepEnv = await prompter.confirm({ + message: "DISCORD_BOT_TOKEN detected. Use env var?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + discord: { + ...next.discord, + enabled: true, + }, + }; + } else { + token = String( + await prompter.text({ + message: "Enter Discord bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (hasConfigToken) { + const keep = await prompter.confirm({ + message: "Discord token already configured. Keep it?", + initialValue: true, + }); + if (!keep) { + token = String( + await prompter.text({ + message: "Enter Discord bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + token = String( + await prompter.text({ + message: "Enter Discord bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (token) { + if (discordAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + discord: { + ...next.discord, + enabled: true, + token, + }, + }; + } else { + next = { + ...next, + discord: { + ...next.discord, + enabled: true, + accounts: { + ...next.discord?.accounts, + [discordAccountId]: { + ...next.discord?.accounts?.[discordAccountId], + enabled: + next.discord?.accounts?.[discordAccountId]?.enabled ?? true, + token, + }, + }, + }, + }; + } + } + + return { cfg: next, accountId: discordAccountId }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + discord: { ...cfg.discord, enabled: false }, + }), +}; diff --git a/src/providers/plugins/onboarding/helpers.ts b/src/providers/plugins/onboarding/helpers.ts new file mode 100644 index 000000000..7375b4648 --- /dev/null +++ b/src/providers/plugins/onboarding/helpers.ts @@ -0,0 +1,50 @@ +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../../routing/session-key.js"; +import type { + PromptAccountId, + PromptAccountIdParams, +} from "../onboarding-types.js"; + +export const promptAccountId: PromptAccountId = async ( + params: PromptAccountIdParams, +) => { + const existingIds = params.listAccountIds(params.cfg); + const initial = + params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID; + const choice = (await params.prompter.select({ + message: `${params.label} account`, + options: [ + ...existingIds.map((id) => ({ + value: id, + label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, + })), + { value: "__new__", label: "Add a new account" }, + ], + initialValue: initial, + })) as string; + + if (choice !== "__new__") return normalizeAccountId(choice); + + const entered = await params.prompter.text({ + message: `New ${params.label} account id`, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const normalized = normalizeAccountId(String(entered)); + if (String(entered).trim() !== normalized) { + await params.prompter.note( + `Normalized account id to "${normalized}".`, + `${params.label} account`, + ); + } + return normalized; +}; + +export function addWildcardAllowFrom( + allowFrom?: Array | null, +): Array { + const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean); + if (!next.includes("*")) next.push("*"); + return next; +} diff --git a/src/providers/plugins/onboarding/imessage.ts b/src/providers/plugins/onboarding/imessage.ts new file mode 100644 index 000000000..d9acb7203 --- /dev/null +++ b/src/providers/plugins/onboarding/imessage.ts @@ -0,0 +1,164 @@ +import { detectBinary } from "../../../commands/onboard-helpers.js"; +import type { ClawdbotConfig } from "../../../config/config.js"; +import type { DmPolicy } from "../../../config/types.js"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, +} from "../../../imessage/accounts.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../../routing/session-key.js"; +import { formatDocsLink } from "../../../terminal/links.js"; +import type { + ProviderOnboardingAdapter, + ProviderOnboardingDmPolicy, +} from "../onboarding-types.js"; +import { addWildcardAllowFrom, promptAccountId } from "./helpers.js"; + +const provider = "imessage" as const; + +function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.imessage?.allowFrom) + : undefined; + return { + ...cfg, + imessage: { + ...cfg.imessage, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }; +} + +const dmPolicy: ProviderOnboardingDmPolicy = { + label: "iMessage", + provider, + policyKey: "imessage.dmPolicy", + allowFromKey: "imessage.allowFrom", + getCurrent: (cfg) => cfg.imessage?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setIMessageDmPolicy(cfg, policy), +}; + +export const imessageOnboardingAdapter: ProviderOnboardingAdapter = { + provider, + getStatus: async ({ cfg }) => { + const configured = 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, + ); + }); + const imessageCliPath = cfg.imessage?.cliPath ?? "imsg"; + const imessageCliDetected = await detectBinary(imessageCliPath); + return { + provider, + configured, + statusLines: [ + `iMessage: ${configured ? "configured" : "needs setup"}`, + `imsg: ${imessageCliDetected ? "found" : "missing"} (${imessageCliPath})`, + ], + selectionHint: imessageCliDetected ? "imsg found" : "imsg missing", + quickstartScore: imessageCliDetected ? 1 : 0, + }; + }, + configure: async ({ + cfg, + prompter, + accountOverrides, + shouldPromptAccountIds, + }) => { + const imessageOverride = accountOverrides.imessage?.trim(); + const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg); + let imessageAccountId = imessageOverride + ? normalizeAccountId(imessageOverride) + : defaultIMessageAccountId; + if (shouldPromptAccountIds && !imessageOverride) { + imessageAccountId = await promptAccountId({ + cfg, + prompter, + label: "iMessage", + currentId: imessageAccountId, + listAccountIds: listIMessageAccountIds, + defaultAccountId: defaultIMessageAccountId, + }); + } + + let next = cfg; + const resolvedAccount = resolveIMessageAccount({ + cfg: next, + accountId: imessageAccountId, + }); + let resolvedCliPath = resolvedAccount.config.cliPath ?? "imsg"; + const cliDetected = await detectBinary(resolvedCliPath); + if (!cliDetected) { + const entered = await prompter.text({ + message: "imsg CLI path", + initialValue: resolvedCliPath, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + resolvedCliPath = String(entered).trim(); + if (!resolvedCliPath) { + await prompter.note( + "imsg CLI path required to enable iMessage.", + "iMessage", + ); + } + } + + if (resolvedCliPath) { + if (imessageAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + imessage: { + ...next.imessage, + enabled: true, + cliPath: resolvedCliPath, + }, + }; + } else { + next = { + ...next, + imessage: { + ...next.imessage, + enabled: true, + accounts: { + ...next.imessage?.accounts, + [imessageAccountId]: { + ...next.imessage?.accounts?.[imessageAccountId], + enabled: + next.imessage?.accounts?.[imessageAccountId]?.enabled ?? true, + cliPath: resolvedCliPath, + }, + }, + }, + }; + } + } + + await prompter.note( + [ + "This is still a work in progress.", + "Ensure Clawdbot 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")}`, + ].join("\n"), + "iMessage next steps", + ); + + return { cfg: next, accountId: imessageAccountId }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + imessage: { ...cfg.imessage, enabled: false }, + }), +}; diff --git a/src/providers/plugins/onboarding/msteams.ts b/src/providers/plugins/onboarding/msteams.ts new file mode 100644 index 000000000..4425c790f --- /dev/null +++ b/src/providers/plugins/onboarding/msteams.ts @@ -0,0 +1,193 @@ +import type { ClawdbotConfig } from "../../../config/config.js"; +import type { DmPolicy } from "../../../config/types.js"; +import { resolveMSTeamsCredentials } from "../../../msteams/token.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; +import { formatDocsLink } from "../../../terminal/links.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; +import type { + ProviderOnboardingAdapter, + ProviderOnboardingDmPolicy, +} from "../onboarding-types.js"; +import { addWildcardAllowFrom } from "./helpers.js"; + +const provider = "msteams" as const; + +function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.msteams?.allowFrom)?.map((entry) => + String(entry), + ) + : undefined; + return { + ...cfg, + msteams: { + ...cfg.msteams, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }; +} + +async function noteMSTeamsCredentialHelp( + prompter: WizardPrompter, +): Promise { + await prompter.note( + [ + "1) Azure Bot registration → get App ID + Tenant ID", + "2) Add a client secret (App Password)", + "3) Set webhook URL + messaging endpoint", + "Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.", + `Docs: ${formatDocsLink("/msteams", "msteams")}`, + ].join("\n"), + "MS Teams credentials", + ); +} + +const dmPolicy: ProviderOnboardingDmPolicy = { + label: "MS Teams", + provider, + policyKey: "msteams.dmPolicy", + allowFromKey: "msteams.allowFrom", + getCurrent: (cfg) => cfg.msteams?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy), +}; + +export const msteamsOnboardingAdapter: ProviderOnboardingAdapter = { + provider, + getStatus: async ({ cfg }) => { + const configured = Boolean(resolveMSTeamsCredentials(cfg.msteams)); + return { + provider, + configured, + statusLines: [ + `MS Teams: ${configured ? "configured" : "needs app credentials"}`, + ], + selectionHint: configured ? "configured" : "needs app creds", + quickstartScore: configured ? 2 : 0, + }; + }, + configure: async ({ cfg, prompter }) => { + const resolved = resolveMSTeamsCredentials(cfg.msteams); + const hasConfigCreds = Boolean( + cfg.msteams?.appId?.trim() && + cfg.msteams?.appPassword?.trim() && + cfg.msteams?.tenantId?.trim(), + ); + const canUseEnv = Boolean( + !hasConfigCreds && + process.env.MSTEAMS_APP_ID?.trim() && + process.env.MSTEAMS_APP_PASSWORD?.trim() && + process.env.MSTEAMS_TENANT_ID?.trim(), + ); + + let next = cfg; + let appId: string | null = null; + let appPassword: string | null = null; + let tenantId: string | null = null; + + if (!resolved) { + await noteMSTeamsCredentialHelp(prompter); + } + + if (canUseEnv) { + const keepEnv = await prompter.confirm({ + message: + "MSTEAMS_APP_ID + MSTEAMS_APP_PASSWORD + MSTEAMS_TENANT_ID detected. Use env vars?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + msteams: { + ...next.msteams, + enabled: true, + }, + }; + } else { + appId = String( + await prompter.text({ + message: "Enter MS Teams App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appPassword = String( + await prompter.text({ + message: "Enter MS Teams App Password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + tenantId = String( + await prompter.text({ + message: "Enter MS Teams Tenant ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (hasConfigCreds) { + const keep = await prompter.confirm({ + message: "MS Teams credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + appId = String( + await prompter.text({ + message: "Enter MS Teams App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appPassword = String( + await prompter.text({ + message: "Enter MS Teams App Password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + tenantId = String( + await prompter.text({ + message: "Enter MS Teams Tenant ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + appId = String( + await prompter.text({ + message: "Enter MS Teams App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appPassword = String( + await prompter.text({ + message: "Enter MS Teams App Password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + tenantId = String( + await prompter.text({ + message: "Enter MS Teams Tenant ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (appId && appPassword && tenantId) { + next = { + ...next, + msteams: { + ...next.msteams, + enabled: true, + appId, + appPassword, + tenantId, + }, + }; + } + + return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + msteams: { ...cfg.msteams, enabled: false }, + }), +}; diff --git a/src/providers/plugins/onboarding/signal.ts b/src/providers/plugins/onboarding/signal.ts new file mode 100644 index 000000000..fc4565c59 --- /dev/null +++ b/src/providers/plugins/onboarding/signal.ts @@ -0,0 +1,206 @@ +import { detectBinary } from "../../../commands/onboard-helpers.js"; +import { installSignalCli } from "../../../commands/signal-install.js"; +import type { ClawdbotConfig } from "../../../config/config.js"; +import type { DmPolicy } from "../../../config/types.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../../routing/session-key.js"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "../../../signal/accounts.js"; +import { formatDocsLink } from "../../../terminal/links.js"; +import type { + ProviderOnboardingAdapter, + ProviderOnboardingDmPolicy, +} from "../onboarding-types.js"; +import { addWildcardAllowFrom, promptAccountId } from "./helpers.js"; + +const provider = "signal" as const; + +function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.signal?.allowFrom) + : undefined; + return { + ...cfg, + signal: { + ...cfg.signal, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }; +} + +const dmPolicy: ProviderOnboardingDmPolicy = { + label: "Signal", + provider, + policyKey: "signal.dmPolicy", + allowFromKey: "signal.allowFrom", + getCurrent: (cfg) => cfg.signal?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setSignalDmPolicy(cfg, policy), +}; + +export const signalOnboardingAdapter: ProviderOnboardingAdapter = { + provider, + getStatus: async ({ cfg }) => { + const configured = listSignalAccountIds(cfg).some( + (accountId) => resolveSignalAccount({ cfg, accountId }).configured, + ); + const signalCliPath = cfg.signal?.cliPath ?? "signal-cli"; + const signalCliDetected = await detectBinary(signalCliPath); + return { + provider, + configured, + statusLines: [ + `Signal: ${configured ? "configured" : "needs setup"}`, + `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, + ], + selectionHint: signalCliDetected + ? "signal-cli found" + : "signal-cli missing", + quickstartScore: signalCliDetected ? 1 : 0, + }; + }, + configure: async ({ + cfg, + runtime, + prompter, + accountOverrides, + shouldPromptAccountIds, + options, + }) => { + const signalOverride = accountOverrides.signal?.trim(); + const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg); + let signalAccountId = signalOverride + ? normalizeAccountId(signalOverride) + : defaultSignalAccountId; + if (shouldPromptAccountIds && !signalOverride) { + signalAccountId = await promptAccountId({ + cfg, + prompter, + label: "Signal", + currentId: signalAccountId, + listAccountIds: listSignalAccountIds, + defaultAccountId: defaultSignalAccountId, + }); + } + + let next = cfg; + const resolvedAccount = resolveSignalAccount({ + cfg: next, + accountId: signalAccountId, + }); + const accountConfig = resolvedAccount.config; + let resolvedCliPath = accountConfig.cliPath ?? "signal-cli"; + let cliDetected = await detectBinary(resolvedCliPath); + if (options?.allowSignalInstall) { + const wantsInstall = await prompter.confirm({ + message: cliDetected + ? "signal-cli detected. Reinstall/update now?" + : "signal-cli not found. Install now?", + initialValue: !cliDetected, + }); + if (wantsInstall) { + try { + const result = await installSignalCli(runtime); + if (result.ok && result.cliPath) { + cliDetected = true; + resolvedCliPath = result.cliPath; + await prompter.note( + `Installed signal-cli at ${result.cliPath}`, + "Signal", + ); + } else if (!result.ok) { + await prompter.note( + result.error ?? "signal-cli install failed.", + "Signal", + ); + } + } catch (err) { + await prompter.note( + `signal-cli install failed: ${String(err)}`, + "Signal", + ); + } + } + } + + if (!cliDetected) { + await prompter.note( + "signal-cli not found. Install it, then rerun this step or set signal.cliPath.", + "Signal", + ); + } + + let account = accountConfig.account ?? ""; + if (account) { + const keep = await prompter.confirm({ + message: `Signal account set (${account}). Keep it?`, + initialValue: true, + }); + if (!keep) account = ""; + } + + if (!account) { + account = String( + await prompter.text({ + message: "Signal bot number (E.164)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (account) { + if (signalAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + signal: { + ...next.signal, + enabled: true, + account, + cliPath: resolvedCliPath ?? "signal-cli", + }, + }; + } else { + next = { + ...next, + signal: { + ...next.signal, + enabled: true, + accounts: { + ...next.signal?.accounts, + [signalAccountId]: { + ...next.signal?.accounts?.[signalAccountId], + enabled: + next.signal?.accounts?.[signalAccountId]?.enabled ?? true, + account, + cliPath: resolvedCliPath ?? "signal-cli", + }, + }, + }, + }; + } + } + + await prompter.note( + [ + 'Link device with: signal-cli link -n "Clawdbot"', + "Scan QR in Signal → Linked Devices", + "Then run: clawdbot gateway call providers.status --params '{\"probe\":true}'", + `Docs: ${formatDocsLink("/signal", "signal")}`, + ].join("\n"), + "Signal next steps", + ); + + return { cfg: next, accountId: signalAccountId }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + signal: { ...cfg.signal, enabled: false }, + }), +}; diff --git a/src/providers/plugins/onboarding/slack.ts b/src/providers/plugins/onboarding/slack.ts new file mode 100644 index 000000000..e2d2dfec4 --- /dev/null +++ b/src/providers/plugins/onboarding/slack.ts @@ -0,0 +1,309 @@ +import type { ClawdbotConfig } from "../../../config/config.js"; +import type { DmPolicy } from "../../../config/types.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../../routing/session-key.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, +} from "../../../slack/accounts.js"; +import { formatDocsLink } from "../../../terminal/links.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; +import type { + ProviderOnboardingAdapter, + ProviderOnboardingDmPolicy, +} from "../onboarding-types.js"; +import { addWildcardAllowFrom, promptAccountId } from "./helpers.js"; + +const provider = "slack" as const; + +function setSlackDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.slack?.dm?.allowFrom) + : undefined; + return { + ...cfg, + slack: { + ...cfg.slack, + dm: { + ...cfg.slack?.dm, + enabled: cfg.slack?.dm?.enabled ?? true, + policy: dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +function buildSlackManifest(botName: string) { + const safeName = botName.trim() || "Clawdbot"; + const manifest = { + display_information: { + name: safeName, + description: `${safeName} connector for Clawdbot`, + }, + features: { + bot_user: { + display_name: safeName, + always_online: false, + }, + app_home: { + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + slash_commands: [ + { + command: "/clawd", + description: "Send a message to Clawdbot", + should_escape: false, + }, + ], + }, + oauth_config: { + scopes: { + bot: [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "reactions:read", + "reactions:write", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write", + ], + }, + }, + settings: { + socket_mode_enabled: true, + event_subscriptions: { + bot_events: [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed", + ], + }, + }, + }; + return JSON.stringify(manifest, null, 2); +} + +async function noteSlackTokenHelp( + prompter: WizardPrompter, + botName: string, +): Promise { + const manifest = buildSlackManifest(botName); + await prompter.note( + [ + "1) Slack API → Create App → From scratch", + "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", + "3) OAuth & Permissions → install app to workspace (xoxb- bot token)", + "4) Enable Event Subscriptions (socket) for message events", + "5) App Home → enable the Messages tab for DMs", + "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", + `Docs: ${formatDocsLink("/slack", "slack")}`, + "", + "Manifest (JSON):", + manifest, + ].join("\n"), + "Slack socket mode tokens", + ); +} + +const dmPolicy: ProviderOnboardingDmPolicy = { + label: "Slack", + provider, + policyKey: "slack.dm.policy", + allowFromKey: "slack.dm.allowFrom", + getCurrent: (cfg) => cfg.slack?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => setSlackDmPolicy(cfg, policy), +}; + +export const slackOnboardingAdapter: ProviderOnboardingAdapter = { + provider, + getStatus: async ({ cfg }) => { + const configured = listSlackAccountIds(cfg).some((accountId) => { + const account = resolveSlackAccount({ cfg, accountId }); + return Boolean(account.botToken && account.appToken); + }); + return { + provider, + configured, + statusLines: [`Slack: ${configured ? "configured" : "needs tokens"}`], + selectionHint: configured ? "configured" : "needs tokens", + quickstartScore: configured ? 2 : 1, + }; + }, + configure: async ({ + cfg, + prompter, + accountOverrides, + shouldPromptAccountIds, + }) => { + const slackOverride = accountOverrides.slack?.trim(); + const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg); + let slackAccountId = slackOverride + ? normalizeAccountId(slackOverride) + : defaultSlackAccountId; + if (shouldPromptAccountIds && !slackOverride) { + slackAccountId = await promptAccountId({ + cfg, + prompter, + label: "Slack", + currentId: slackAccountId, + listAccountIds: listSlackAccountIds, + defaultAccountId: defaultSlackAccountId, + }); + } + + let next = cfg; + const resolvedAccount = resolveSlackAccount({ + cfg: next, + accountId: slackAccountId, + }); + const accountConfigured = Boolean( + resolvedAccount.botToken && resolvedAccount.appToken, + ); + const allowEnv = slackAccountId === DEFAULT_ACCOUNT_ID; + const canUseEnv = + allowEnv && + Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && + Boolean(process.env.SLACK_APP_TOKEN?.trim()); + const hasConfigTokens = Boolean( + resolvedAccount.config.botToken && resolvedAccount.config.appToken, + ); + + let botToken: string | null = null; + let appToken: string | null = null; + const slackBotName = String( + await prompter.text({ + message: "Slack bot display name (used for manifest)", + initialValue: "Clawdbot", + }), + ).trim(); + if (!accountConfigured) { + await noteSlackTokenHelp(prompter, slackBotName); + } + if ( + canUseEnv && + (!resolvedAccount.config.botToken || !resolvedAccount.config.appToken) + ) { + const keepEnv = await prompter.confirm({ + message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + slack: { + ...next.slack, + enabled: true, + }, + }; + } else { + botToken = String( + await prompter.text({ + message: "Enter Slack bot token (xoxb-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appToken = String( + await prompter.text({ + message: "Enter Slack app token (xapp-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (hasConfigTokens) { + const keep = await prompter.confirm({ + message: "Slack tokens already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + botToken = String( + await prompter.text({ + message: "Enter Slack bot token (xoxb-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appToken = String( + await prompter.text({ + message: "Enter Slack app token (xapp-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + botToken = String( + await prompter.text({ + message: "Enter Slack bot token (xoxb-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appToken = String( + await prompter.text({ + message: "Enter Slack app token (xapp-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (botToken && appToken) { + if (slackAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + slack: { + ...next.slack, + enabled: true, + botToken, + appToken, + }, + }; + } else { + next = { + ...next, + slack: { + ...next.slack, + enabled: true, + accounts: { + ...next.slack?.accounts, + [slackAccountId]: { + ...next.slack?.accounts?.[slackAccountId], + enabled: + next.slack?.accounts?.[slackAccountId]?.enabled ?? true, + botToken, + appToken, + }, + }, + }, + }; + } + } + + return { cfg: next, accountId: slackAccountId }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + slack: { ...cfg.slack, enabled: false }, + }), +}; diff --git a/src/providers/plugins/onboarding/telegram.ts b/src/providers/plugins/onboarding/telegram.ts new file mode 100644 index 000000000..7ae0af12f --- /dev/null +++ b/src/providers/plugins/onboarding/telegram.ts @@ -0,0 +1,262 @@ +import type { ClawdbotConfig } from "../../../config/config.js"; +import type { DmPolicy } from "../../../config/types.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../../routing/session-key.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "../../../telegram/accounts.js"; +import { formatDocsLink } from "../../../terminal/links.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; +import type { + ProviderOnboardingAdapter, + ProviderOnboardingDmPolicy, +} from "../onboarding-types.js"; +import { addWildcardAllowFrom, promptAccountId } from "./helpers.js"; + +const provider = "telegram" as const; + +function setTelegramDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.telegram?.allowFrom) + : undefined; + return { + ...cfg, + telegram: { + ...cfg.telegram, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }; +} + +async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Open Telegram and chat with @BotFather", + "2) Run /newbot (or /mybots)", + "3) Copy the token (looks like 123456:ABC...)", + "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://clawd.bot", + ].join("\n"), + "Telegram bot token", + ); +} + +async function promptTelegramAllowFrom(params: { + cfg: ClawdbotConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const { cfg, prompter, accountId } = params; + const resolved = resolveTelegramAccount({ cfg, accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + const entry = await prompter.text({ + message: "Telegram allowFrom (user id)", + placeholder: "123456789", + initialValue: existingAllowFrom[0] + ? String(existingAllowFrom[0]) + : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + if (!/^\d+$/.test(raw)) return "Use a numeric Telegram user id"; + return undefined; + }, + }); + const normalized = String(entry).trim(); + const merged = [ + ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), + normalized, + ]; + const unique = [...new Set(merged)]; + + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + telegram: { + ...cfg.telegram, + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }; + } + + return { + ...cfg, + telegram: { + ...cfg.telegram, + enabled: true, + accounts: { + ...cfg.telegram?.accounts, + [accountId]: { + ...cfg.telegram?.accounts?.[accountId], + enabled: cfg.telegram?.accounts?.[accountId]?.enabled ?? true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }, + }; +} + +const dmPolicy: ProviderOnboardingDmPolicy = { + label: "Telegram", + provider, + policyKey: "telegram.dmPolicy", + allowFromKey: "telegram.allowFrom", + getCurrent: (cfg) => cfg.telegram?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setTelegramDmPolicy(cfg, policy), +}; + +export const telegramOnboardingAdapter: ProviderOnboardingAdapter = { + provider, + getStatus: async ({ cfg }) => { + const configured = listTelegramAccountIds(cfg).some((accountId) => + Boolean(resolveTelegramAccount({ cfg, accountId }).token), + ); + return { + provider, + configured, + statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`], + selectionHint: configured + ? "recommended · configured" + : "recommended · newcomer-friendly", + quickstartScore: configured ? 1 : 10, + }; + }, + configure: async ({ + cfg, + prompter, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom, + }) => { + const telegramOverride = accountOverrides.telegram?.trim(); + const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg); + let telegramAccountId = telegramOverride + ? normalizeAccountId(telegramOverride) + : defaultTelegramAccountId; + if (shouldPromptAccountIds && !telegramOverride) { + telegramAccountId = await promptAccountId({ + cfg, + prompter, + label: "Telegram", + currentId: telegramAccountId, + listAccountIds: listTelegramAccountIds, + defaultAccountId: defaultTelegramAccountId, + }); + } + + let next = cfg; + const resolvedAccount = resolveTelegramAccount({ + cfg: next, + accountId: telegramAccountId, + }); + const accountConfigured = Boolean(resolvedAccount.token); + const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID; + const canUseEnv = + allowEnv && Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim()); + const hasConfigToken = Boolean( + resolvedAccount.config.botToken || resolvedAccount.config.tokenFile, + ); + + let token: string | null = null; + if (!accountConfigured) { + await noteTelegramTokenHelp(prompter); + } + if (canUseEnv && !resolvedAccount.config.botToken) { + const keepEnv = await prompter.confirm({ + message: "TELEGRAM_BOT_TOKEN detected. Use env var?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + telegram: { + ...next.telegram, + enabled: true, + }, + }; + } else { + token = String( + await prompter.text({ + message: "Enter Telegram bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (hasConfigToken) { + const keep = await prompter.confirm({ + message: "Telegram token already configured. Keep it?", + initialValue: true, + }); + if (!keep) { + token = String( + await prompter.text({ + message: "Enter Telegram bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + token = String( + await prompter.text({ + message: "Enter Telegram bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (token) { + if (telegramAccountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + telegram: { + ...next.telegram, + enabled: true, + botToken: token, + }, + }; + } else { + next = { + ...next, + telegram: { + ...next.telegram, + enabled: true, + accounts: { + ...next.telegram?.accounts, + [telegramAccountId]: { + ...next.telegram?.accounts?.[telegramAccountId], + enabled: + next.telegram?.accounts?.[telegramAccountId]?.enabled ?? true, + botToken: token, + }, + }, + }, + }; + } + } + + if (forceAllowFrom) { + next = await promptTelegramAllowFrom({ + cfg: next, + prompter, + accountId: telegramAccountId, + }); + } + + return { cfg: next, accountId: telegramAccountId }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + telegram: { ...cfg.telegram, enabled: false }, + }), +}; diff --git a/src/providers/plugins/onboarding/whatsapp.ts b/src/providers/plugins/onboarding/whatsapp.ts new file mode 100644 index 000000000..0d68475a5 --- /dev/null +++ b/src/providers/plugins/onboarding/whatsapp.ts @@ -0,0 +1,399 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ClawdbotConfig } from "../../../config/config.js"; +import { mergeWhatsAppConfig } from "../../../config/merge-config.js"; +import type { DmPolicy } from "../../../config/types.js"; +import { loginWeb } from "../../../provider-web.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../../routing/session-key.js"; +import type { RuntimeEnv } from "../../../runtime.js"; +import { formatDocsLink } from "../../../terminal/links.js"; +import { normalizeE164 } from "../../../utils.js"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAuthDir, +} from "../../../web/accounts.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; +import type { ProviderOnboardingAdapter } from "../onboarding-types.js"; +import { promptAccountId } from "./helpers.js"; + +const provider = "whatsapp" as const; + +function setWhatsAppDmPolicy( + cfg: ClawdbotConfig, + dmPolicy: DmPolicy, +): ClawdbotConfig { + return mergeWhatsAppConfig(cfg, { dmPolicy }); +} + +function setWhatsAppAllowFrom( + cfg: ClawdbotConfig, + allowFrom?: string[], +): ClawdbotConfig { + return mergeWhatsAppConfig( + cfg, + { allowFrom }, + { unsetOnUndefined: ["allowFrom"] }, + ); +} + +function setMessagesResponsePrefix( + cfg: ClawdbotConfig, + responsePrefix?: string, +): ClawdbotConfig { + return { + ...cfg, + messages: { + ...cfg.messages, + responsePrefix, + }, + }; +} + +function setWhatsAppSelfChatMode( + cfg: ClawdbotConfig, + selfChatMode: boolean, +): ClawdbotConfig { + return mergeWhatsAppConfig(cfg, { selfChatMode }); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function detectWhatsAppLinked( + cfg: ClawdbotConfig, + accountId: string, +): Promise { + const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); + const credsPath = path.join(authDir, "creds.json"); + return await pathExists(credsPath); +} + +async function promptWhatsAppAllowFrom( + cfg: ClawdbotConfig, + _runtime: RuntimeEnv, + prompter: WizardPrompter, + options?: { forceAllowlist?: boolean }, +): Promise { + const existingPolicy = cfg.whatsapp?.dmPolicy ?? "pairing"; + const existingAllowFrom = cfg.whatsapp?.allowFrom ?? []; + const existingLabel = + existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; + const existingResponsePrefix = cfg.messages?.responsePrefix; + + if (options?.forceAllowlist) { + await prompter.note( + "We need the sender/owner number so Clawdbot can allowlist you.", + "WhatsApp number", + ); + const entry = await prompter.text({ + message: + "Your personal WhatsApp number (the phone you will message from)", + placeholder: "+15555550123", + initialValue: existingAllowFrom[0], + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + const normalized = normalizeE164(raw); + if (!normalized) return `Invalid number: ${raw}`; + return undefined; + }, + }); + const normalized = normalizeE164(String(entry).trim()); + const merged = [ + ...existingAllowFrom + .filter((item) => item !== "*") + .map((item) => normalizeE164(item)) + .filter(Boolean), + normalized, + ]; + const unique = [...new Set(merged.filter(Boolean))]; + let next = setWhatsAppSelfChatMode(cfg, true); + next = setWhatsAppDmPolicy(next, "allowlist"); + next = setWhatsAppAllowFrom(next, unique); + if (existingResponsePrefix === undefined) { + next = setMessagesResponsePrefix(next, "[clawdbot]"); + } + await prompter.note( + [ + "Allowlist mode enabled.", + `- allowFrom includes ${normalized}`, + existingResponsePrefix === undefined + ? "- responsePrefix set to [clawdbot]" + : "- responsePrefix left unchanged", + ].join("\n"), + "WhatsApp allowlist", + ); + return next; + } + + await prompter.note( + [ + "WhatsApp direct chats are gated by `whatsapp.dmPolicy` + `whatsapp.allowFrom`.", + "- pairing (default): unknown senders get a pairing code; owner approves", + "- allowlist: unknown senders are blocked", + '- open: public inbound DMs (requires allowFrom to include "*")', + "- disabled: ignore WhatsApp DMs", + "", + `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, + ].join("\n"), + "WhatsApp DM access", + ); + + const phoneMode = (await prompter.select({ + message: "WhatsApp phone setup", + options: [ + { value: "personal", label: "This is my personal phone number" }, + { value: "separate", label: "Separate phone just for Clawdbot" }, + ], + })) as "personal" | "separate"; + + if (phoneMode === "personal") { + await prompter.note( + "We need the sender/owner number so Clawdbot can allowlist you.", + "WhatsApp number", + ); + const entry = await prompter.text({ + message: + "Your personal WhatsApp number (the phone you will message from)", + placeholder: "+15555550123", + initialValue: existingAllowFrom[0], + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + const normalized = normalizeE164(raw); + if (!normalized) return `Invalid number: ${raw}`; + return undefined; + }, + }); + const normalized = normalizeE164(String(entry).trim()); + const merged = [ + ...existingAllowFrom + .filter((item) => item !== "*") + .map((item) => normalizeE164(item)) + .filter(Boolean), + normalized, + ]; + const unique = [...new Set(merged.filter(Boolean))]; + let next = setWhatsAppSelfChatMode(cfg, true); + next = setWhatsAppDmPolicy(next, "allowlist"); + next = setWhatsAppAllowFrom(next, unique); + if (existingResponsePrefix === undefined) { + next = setMessagesResponsePrefix(next, "[clawdbot]"); + } + await prompter.note( + [ + "Personal phone mode enabled.", + "- dmPolicy set to allowlist (pairing skipped)", + `- allowFrom includes ${normalized}`, + existingResponsePrefix === undefined + ? "- responsePrefix set to [clawdbot]" + : "- responsePrefix left unchanged", + ].join("\n"), + "WhatsApp personal phone", + ); + return next; + } + + const policy = (await prompter.select({ + message: "WhatsApp DM policy", + options: [ + { value: "pairing", label: "Pairing (recommended)" }, + { value: "allowlist", label: "Allowlist only (block unknown senders)" }, + { value: "open", label: "Open (public inbound DMs)" }, + { value: "disabled", label: "Disabled (ignore WhatsApp DMs)" }, + ], + })) as DmPolicy; + + let next = setWhatsAppSelfChatMode(cfg, false); + next = setWhatsAppDmPolicy(next, policy); + if (policy === "open") { + next = setWhatsAppAllowFrom(next, ["*"]); + } + if (policy === "disabled") return next; + + const allowOptions = + existingAllowFrom.length > 0 + ? ([ + { value: "keep", label: "Keep current allowFrom" }, + { + value: "unset", + label: "Unset allowFrom (use pairing approvals only)", + }, + { value: "list", label: "Set allowFrom to specific numbers" }, + ] as const) + : ([ + { value: "unset", label: "Unset allowFrom (default)" }, + { value: "list", label: "Set allowFrom to specific numbers" }, + ] as const); + + const mode = (await prompter.select({ + message: "WhatsApp allowFrom (optional pre-allowlist)", + options: allowOptions.map((opt) => ({ + value: opt.value, + label: opt.label, + })), + })) as (typeof allowOptions)[number]["value"]; + + if (mode === "keep") { + // Keep allowFrom as-is. + } else if (mode === "unset") { + next = setWhatsAppAllowFrom(next, undefined); + } else { + const allowRaw = await prompter.text({ + message: "Allowed sender numbers (comma-separated, E.164)", + placeholder: "+15555550123, +447700900123", + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + const parts = raw + .split(/[\n,;]+/g) + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length === 0) return "Required"; + for (const part of parts) { + if (part === "*") continue; + const normalized = normalizeE164(part); + if (!normalized) return `Invalid number: ${part}`; + } + return undefined; + }, + }); + + const parts = String(allowRaw) + .split(/[\n,;]+/g) + .map((p) => p.trim()) + .filter(Boolean); + const normalized = parts.map((part) => + part === "*" ? "*" : normalizeE164(part), + ); + const unique = [...new Set(normalized.filter(Boolean))]; + next = setWhatsAppAllowFrom(next, unique); + } + + return next; +} + +export const whatsappOnboardingAdapter: ProviderOnboardingAdapter = { + provider, + getStatus: async ({ cfg, accountOverrides }) => { + const overrideId = accountOverrides.whatsapp?.trim(); + const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg); + const accountId = overrideId + ? normalizeAccountId(overrideId) + : defaultAccountId; + const linked = await detectWhatsAppLinked(cfg, accountId); + const accountLabel = + accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId; + return { + provider, + configured: linked, + statusLines: [ + `WhatsApp (${accountLabel}): ${linked ? "linked" : "not linked"}`, + ], + selectionHint: linked ? "linked" : "not linked", + quickstartScore: linked ? 5 : 4, + }; + }, + configure: async ({ + cfg, + runtime, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom, + }) => { + const overrideId = accountOverrides.whatsapp?.trim(); + let accountId = overrideId + ? normalizeAccountId(overrideId) + : resolveDefaultWhatsAppAccountId(cfg); + if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) { + if (!overrideId) { + accountId = await promptAccountId({ + cfg, + prompter, + label: "WhatsApp", + currentId: accountId, + listAccountIds: listWhatsAppAccountIds, + defaultAccountId: resolveDefaultWhatsAppAccountId(cfg), + }); + } + } + + let next = cfg; + if (accountId !== DEFAULT_ACCOUNT_ID) { + next = { + ...next, + whatsapp: { + ...next.whatsapp, + accounts: { + ...next.whatsapp?.accounts, + [accountId]: { + ...next.whatsapp?.accounts?.[accountId], + enabled: next.whatsapp?.accounts?.[accountId]?.enabled ?? true, + }, + }, + }, + }; + } + + const linked = await detectWhatsAppLinked(next, accountId); + const { authDir } = resolveWhatsAppAuthDir({ + cfg: next, + accountId, + }); + + if (!linked) { + await prompter.note( + [ + "Scan the QR with WhatsApp on your phone.", + `Credentials are stored under ${authDir}/ for future runs.`, + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, + ].join("\n"), + "WhatsApp linking", + ); + } + const wantsLink = await prompter.confirm({ + message: linked + ? "WhatsApp already linked. Re-link now?" + : "Link WhatsApp now (QR)?", + initialValue: !linked, + }); + if (wantsLink) { + try { + await loginWeb(false, undefined, runtime, accountId); + } catch (err) { + runtime.error(`WhatsApp login failed: ${String(err)}`); + await prompter.note( + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, + "WhatsApp help", + ); + } + } else if (!linked) { + await prompter.note( + "Run `clawdbot providers login` later to link WhatsApp.", + "WhatsApp", + ); + } + + next = await promptWhatsAppAllowFrom(next, runtime, prompter, { + forceAllowlist: forceAllowFrom, + }); + + return { cfg: next, accountId }; + }, + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, +}; diff --git a/src/providers/plugins/outbound/discord.ts b/src/providers/plugins/outbound/discord.ts new file mode 100644 index 000000000..c22a1c0ce --- /dev/null +++ b/src/providers/plugins/outbound/discord.ts @@ -0,0 +1,44 @@ +import { sendMessageDiscord, sendPollDiscord } from "../../../discord/send.js"; +import type { ProviderOutboundAdapter } from "../types.js"; + +export const discordOutbound: ProviderOutboundAdapter = { + deliveryMode: "direct", + chunker: null, + textChunkLimit: 2000, + pollMaxOptions: 10, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Discord requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ to, text, accountId, deps, replyToId }) => { + const send = deps?.sendDiscord ?? sendMessageDiscord; + const result = await send(to, text, { + verbose: false, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { provider: "discord", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { + const send = deps?.sendDiscord ?? sendMessageDiscord; + const result = await send(to, text, { + verbose: false, + mediaUrl, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { provider: "discord", ...result }; + }, + sendPoll: async ({ to, poll, accountId }) => + await sendPollDiscord(to, poll, { + accountId: accountId ?? undefined, + }), +}; diff --git a/src/providers/plugins/outbound/imessage.ts b/src/providers/plugins/outbound/imessage.ts new file mode 100644 index 000000000..0beb88445 --- /dev/null +++ b/src/providers/plugins/outbound/imessage.ts @@ -0,0 +1,53 @@ +import { chunkText } from "../../../auto-reply/chunk.js"; +import { sendMessageIMessage } from "../../../imessage/send.js"; +import { resolveProviderMediaMaxBytes } from "../media-limits.js"; +import type { ProviderOutboundAdapter } from "../types.js"; + +export const imessageOutbound: ProviderOutboundAdapter = { + deliveryMode: "direct", + chunker: chunkText, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to iMessage requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = deps?.sendIMessage ?? sendMessageIMessage; + const maxBytes = resolveProviderMediaMaxBytes({ + cfg, + resolveProviderLimitMb: ({ cfg, accountId }) => + cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.imessage?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + maxBytes, + accountId: accountId ?? undefined, + }); + return { provider: "imessage", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { + const send = deps?.sendIMessage ?? sendMessageIMessage; + const maxBytes = resolveProviderMediaMaxBytes({ + cfg, + resolveProviderLimitMb: ({ cfg, accountId }) => + cfg.imessage?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.imessage?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + }); + return { provider: "imessage", ...result }; + }, +}; diff --git a/src/providers/plugins/outbound/load.ts b/src/providers/plugins/outbound/load.ts new file mode 100644 index 000000000..471c719a9 --- /dev/null +++ b/src/providers/plugins/outbound/load.ts @@ -0,0 +1,32 @@ +import type { ProviderId, ProviderOutboundAdapter } from "../types.js"; + +type OutboundLoader = () => Promise; + +// Provider docking: outbound sends should stay cheap to import. +// +// The full provider plugins (src/providers/plugins/*.ts) pull in status, +// onboarding, gateway monitors, etc. Outbound delivery only needs chunking + +// send primitives, so we keep a dedicated, lightweight loader here. +const LOADERS: Record = { + telegram: async () => (await import("./telegram.js")).telegramOutbound, + whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound, + discord: async () => (await import("./discord.js")).discordOutbound, + slack: async () => (await import("./slack.js")).slackOutbound, + signal: async () => (await import("./signal.js")).signalOutbound, + imessage: async () => (await import("./imessage.js")).imessageOutbound, + msteams: async () => (await import("./msteams.js")).msteamsOutbound, +}; + +const cache = new Map(); + +export async function loadProviderOutboundAdapter( + id: ProviderId, +): Promise { + const cached = cache.get(id); + if (cached) return cached; + const loader = LOADERS[id]; + if (!loader) return undefined; + const outbound = await loader(); + cache.set(id, outbound); + return outbound; +} diff --git a/src/providers/plugins/outbound/msteams.ts b/src/providers/plugins/outbound/msteams.ts new file mode 100644 index 000000000..6c25edae4 --- /dev/null +++ b/src/providers/plugins/outbound/msteams.ts @@ -0,0 +1,60 @@ +import { chunkMarkdownText } from "../../../auto-reply/chunk.js"; +import { createMSTeamsPollStoreFs } from "../../../msteams/polls.js"; +import { sendMessageMSTeams, sendPollMSTeams } from "../../../msteams/send.js"; +import type { ProviderOutboundAdapter } from "../types.js"; + +export const msteamsOutbound: ProviderOutboundAdapter = { + deliveryMode: "direct", + chunker: chunkMarkdownText, + textChunkLimit: 4000, + pollMaxOptions: 12, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to MS Teams requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ cfg, to, text, deps }) => { + const send = + deps?.sendMSTeams ?? + ((to, text) => sendMessageMSTeams({ cfg, to, text })); + const result = await send(to, text); + return { provider: "msteams", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => { + const send = + deps?.sendMSTeams ?? + ((to, text, opts) => + sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl })); + const result = await send(to, text, { mediaUrl }); + return { provider: "msteams", ...result }; + }, + sendPoll: async ({ cfg, to, poll }) => { + const maxSelections = poll.maxSelections ?? 1; + const result = await sendPollMSTeams({ + cfg, + to, + question: poll.question, + options: poll.options, + maxSelections, + }); + const pollStore = createMSTeamsPollStoreFs(); + await pollStore.createPoll({ + id: result.pollId, + question: poll.question, + options: poll.options, + maxSelections, + createdAt: new Date().toISOString(), + conversationId: result.conversationId, + messageId: result.messageId, + votes: {}, + }); + return result; + }, +}; diff --git a/src/providers/plugins/outbound/signal.ts b/src/providers/plugins/outbound/signal.ts new file mode 100644 index 000000000..edef39ce3 --- /dev/null +++ b/src/providers/plugins/outbound/signal.ts @@ -0,0 +1,51 @@ +import { chunkText } from "../../../auto-reply/chunk.js"; +import { sendMessageSignal } from "../../../signal/send.js"; +import { resolveProviderMediaMaxBytes } from "../media-limits.js"; +import type { ProviderOutboundAdapter } from "../types.js"; + +export const signalOutbound: ProviderOutboundAdapter = { + deliveryMode: "direct", + chunker: chunkText, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Signal requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = deps?.sendSignal ?? sendMessageSignal; + const maxBytes = resolveProviderMediaMaxBytes({ + cfg, + resolveProviderLimitMb: ({ cfg, accountId }) => + cfg.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.signal?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + maxBytes, + accountId: accountId ?? undefined, + }); + return { provider: "signal", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { + const send = deps?.sendSignal ?? sendMessageSignal; + const maxBytes = resolveProviderMediaMaxBytes({ + cfg, + resolveProviderLimitMb: ({ cfg, accountId }) => + cfg.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.signal?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + }); + return { provider: "signal", ...result }; + }, +}; diff --git a/src/providers/plugins/outbound/slack.ts b/src/providers/plugins/outbound/slack.ts new file mode 100644 index 000000000..47a65ec92 --- /dev/null +++ b/src/providers/plugins/outbound/slack.ts @@ -0,0 +1,37 @@ +import { sendMessageSlack } from "../../../slack/send.js"; +import type { ProviderOutboundAdapter } from "../types.js"; + +export const slackOutbound: ProviderOutboundAdapter = { + deliveryMode: "direct", + chunker: null, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Slack requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ to, text, accountId, deps, replyToId }) => { + const send = deps?.sendSlack ?? sendMessageSlack; + const result = await send(to, text, { + threadTs: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { provider: "slack", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { + const send = deps?.sendSlack ?? sendMessageSlack; + const result = await send(to, text, { + mediaUrl, + threadTs: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { provider: "slack", ...result }; + }, +}; diff --git a/src/providers/plugins/outbound/telegram.ts b/src/providers/plugins/outbound/telegram.ts new file mode 100644 index 000000000..1503db434 --- /dev/null +++ b/src/providers/plugins/outbound/telegram.ts @@ -0,0 +1,56 @@ +import { chunkMarkdownText } from "../../../auto-reply/chunk.js"; +import { sendMessageTelegram } from "../../../telegram/send.js"; +import type { ProviderOutboundAdapter } from "../types.js"; + +function parseReplyToMessageId(replyToId?: string | null) { + if (!replyToId) return undefined; + const parsed = Number.parseInt(replyToId, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export const telegramOutbound: ProviderOutboundAdapter = { + deliveryMode: "direct", + chunker: chunkMarkdownText, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error("Delivering to Telegram requires --to "), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { + const send = deps?.sendTelegram ?? sendMessageTelegram; + const replyToMessageId = parseReplyToMessageId(replyToId); + const result = await send(to, text, { + verbose: false, + messageThreadId: threadId ?? undefined, + replyToMessageId, + accountId: accountId ?? undefined, + }); + return { provider: "telegram", ...result }; + }, + sendMedia: async ({ + to, + text, + mediaUrl, + accountId, + deps, + replyToId, + threadId, + }) => { + const send = deps?.sendTelegram ?? sendMessageTelegram; + const replyToMessageId = parseReplyToMessageId(replyToId); + const result = await send(to, text, { + verbose: false, + mediaUrl, + messageThreadId: threadId ?? undefined, + replyToMessageId, + accountId: accountId ?? undefined, + }); + return { provider: "telegram", ...result }; + }, +}; diff --git a/src/providers/plugins/outbound/whatsapp.ts b/src/providers/plugins/outbound/whatsapp.ts new file mode 100644 index 000000000..beb1e74f5 --- /dev/null +++ b/src/providers/plugins/outbound/whatsapp.ts @@ -0,0 +1,94 @@ +import { chunkText } from "../../../auto-reply/chunk.js"; +import { shouldLogVerbose } from "../../../globals.js"; +import { + sendMessageWhatsApp, + sendPollWhatsApp, +} from "../../../web/outbound.js"; +import { + isWhatsAppGroupJid, + normalizeWhatsAppTarget, +} from "../../../whatsapp/normalize.js"; +import type { ProviderOutboundAdapter } from "../types.js"; + +export const whatsappOutbound: ProviderOutboundAdapter = { + deliveryMode: "gateway", + chunker: chunkText, + textChunkLimit: 4000, + pollMaxOptions: 12, + resolveTarget: ({ to, allowFrom, mode }) => { + const trimmed = to?.trim() ?? ""; + const allowListRaw = (allowFrom ?? []) + .map((entry) => String(entry).trim()) + .filter(Boolean); + const hasWildcard = allowListRaw.includes("*"); + const allowList = allowListRaw + .filter((entry) => entry !== "*") + .map((entry) => normalizeWhatsAppTarget(entry)) + .filter((entry): entry is string => Boolean(entry)); + + if (trimmed) { + const normalizedTo = normalizeWhatsAppTarget(trimmed); + if (!normalizedTo) { + if ( + (mode === "implicit" || mode === "heartbeat") && + allowList.length > 0 + ) { + return { ok: true, to: allowList[0] }; + } + return { + ok: false, + error: new Error( + "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", + ), + }; + } + if (isWhatsAppGroupJid(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } + if (mode === "implicit" || mode === "heartbeat") { + if (hasWildcard || allowList.length === 0) { + return { ok: true, to: normalizedTo }; + } + if (allowList.includes(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } + return { ok: true, to: allowList[0] }; + } + return { ok: true, to: normalizedTo }; + } + + if (allowList.length > 0) { + return { ok: true, to: allowList[0] }; + } + return { + ok: false, + error: new Error( + "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", + ), + }; + }, + sendText: async ({ to, text, accountId, deps, gifPlayback }) => { + const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const result = await send(to, text, { + verbose: false, + accountId: accountId ?? undefined, + gifPlayback, + }); + return { provider: "whatsapp", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => { + const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const result = await send(to, text, { + verbose: false, + mediaUrl, + accountId: accountId ?? undefined, + gifPlayback, + }); + return { provider: "whatsapp", ...result }; + }, + sendPoll: async ({ to, poll, accountId }) => + await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId: accountId ?? undefined, + }), +}; diff --git a/src/providers/plugins/pairing-message.ts b/src/providers/plugins/pairing-message.ts new file mode 100644 index 000000000..2a2b590c8 --- /dev/null +++ b/src/providers/plugins/pairing-message.ts @@ -0,0 +1,2 @@ +export const PAIRING_APPROVED_MESSAGE = + "✅ Clawdbot access approved. Send a message to start chatting."; diff --git a/src/providers/plugins/pairing.ts b/src/providers/plugins/pairing.ts new file mode 100644 index 000000000..729ceb828 --- /dev/null +++ b/src/providers/plugins/pairing.ts @@ -0,0 +1,68 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { + getProviderPlugin, + listProviderPlugins, + normalizeProviderId, + type ProviderId, +} from "./index.js"; +import type { ProviderPairingAdapter } from "./types.js"; + +export function listPairingProviders(): ProviderId[] { + // Provider docking: pairing support is declared via plugin.pairing. + return listProviderPlugins() + .filter((plugin) => plugin.pairing) + .map((plugin) => plugin.id); +} + +export function getPairingAdapter( + providerId: ProviderId, +): ProviderPairingAdapter | null { + const plugin = getProviderPlugin(providerId); + return plugin?.pairing ?? null; +} + +export function requirePairingAdapter( + providerId: ProviderId, +): ProviderPairingAdapter { + const adapter = getPairingAdapter(providerId); + if (!adapter) { + throw new Error(`Provider ${providerId} does not support pairing`); + } + return adapter; +} + +export function resolvePairingProvider(raw: unknown): ProviderId { + const value = ( + typeof raw === "string" + ? raw + : typeof raw === "number" || typeof raw === "boolean" + ? String(raw) + : "" + ) + .trim() + .toLowerCase(); + const normalized = normalizeProviderId(value); + const providers = listPairingProviders(); + if (!normalized || !providers.includes(normalized)) { + throw new Error( + `Invalid provider: ${value || "(empty)"} (expected one of: ${providers.join(", ")})`, + ); + } + return normalized; +} + +export async function notifyPairingApproved(params: { + providerId: ProviderId; + id: string; + cfg: ClawdbotConfig; + runtime?: RuntimeEnv; +}): Promise { + const adapter = requirePairingAdapter(params.providerId); + if (!adapter.notifyApproval) return; + await adapter.notifyApproval({ + cfg: params.cfg, + id: params.id, + runtime: params.runtime, + }); +} diff --git a/src/providers/plugins/setup-helpers.ts b/src/providers/plugins/setup-helpers.ts new file mode 100644 index 000000000..373441aa8 --- /dev/null +++ b/src/providers/plugins/setup-helpers.ts @@ -0,0 +1,116 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../routing/session-key.js"; + +type ProviderSectionBase = { + name?: string; + accounts?: Record>; +}; + +function providerHasAccounts( + cfg: ClawdbotConfig, + providerKey: string, +): boolean { + const base = (cfg as Record)[providerKey] as + | ProviderSectionBase + | undefined; + return Boolean(base?.accounts && Object.keys(base.accounts).length > 0); +} + +function shouldStoreNameInAccounts(params: { + cfg: ClawdbotConfig; + providerKey: string; + accountId: string; + alwaysUseAccounts?: boolean; +}): boolean { + if (params.alwaysUseAccounts) return true; + if (params.accountId !== DEFAULT_ACCOUNT_ID) return true; + return providerHasAccounts(params.cfg, params.providerKey); +} + +export function applyAccountNameToProviderSection(params: { + cfg: ClawdbotConfig; + providerKey: string; + accountId: string; + name?: string; + alwaysUseAccounts?: boolean; +}): ClawdbotConfig { + const trimmed = params.name?.trim(); + if (!trimmed) return params.cfg; + const accountId = normalizeAccountId(params.accountId); + const baseConfig = (params.cfg as Record)[ + params.providerKey + ]; + const base = + typeof baseConfig === "object" && baseConfig + ? (baseConfig as ProviderSectionBase) + : undefined; + const useAccounts = shouldStoreNameInAccounts({ + cfg: params.cfg, + providerKey: params.providerKey, + accountId, + alwaysUseAccounts: params.alwaysUseAccounts, + }); + if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) { + const safeBase = base ?? {}; + return { + ...params.cfg, + [params.providerKey]: { + ...safeBase, + name: trimmed, + }, + } as ClawdbotConfig; + } + const baseAccounts: Record< + string, + Record + > = base?.accounts ?? {}; + const existingAccount = baseAccounts[accountId] ?? {}; + const baseWithoutName = + accountId === DEFAULT_ACCOUNT_ID + ? (({ name: _ignored, ...rest }) => rest)(base ?? {}) + : (base ?? {}); + return { + ...params.cfg, + [params.providerKey]: { + ...baseWithoutName, + accounts: { + ...baseAccounts, + [accountId]: { + ...existingAccount, + name: trimmed, + }, + }, + }, + } as ClawdbotConfig; +} + +export function migrateBaseNameToDefaultAccount(params: { + cfg: ClawdbotConfig; + providerKey: string; + alwaysUseAccounts?: boolean; +}): ClawdbotConfig { + if (params.alwaysUseAccounts) return params.cfg; + const base = (params.cfg as Record)[params.providerKey] as + | ProviderSectionBase + | undefined; + const baseName = base?.name?.trim(); + if (!baseName) return params.cfg; + const accounts: Record> = { + ...base?.accounts, + }; + const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {}; + if (!defaultAccount.name) { + accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName }; + } + const { name: _ignored, ...rest } = base ?? {}; + return { + ...params.cfg, + [params.providerKey]: { + ...rest, + accounts, + }, + } as ClawdbotConfig; +} diff --git a/src/providers/plugins/signal.ts b/src/providers/plugins/signal.ts new file mode 100644 index 000000000..2a80b79a2 --- /dev/null +++ b/src/providers/plugins/signal.ts @@ -0,0 +1,314 @@ +import { chunkText } from "../../auto-reply/chunk.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../routing/session-key.js"; +import { + listSignalAccountIds, + type ResolvedSignalAccount, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "../../signal/accounts.js"; +import { probeSignal } from "../../signal/probe.js"; +import { sendMessageSignal } from "../../signal/send.js"; +import { normalizeE164 } from "../../utils.js"; +import { getChatProviderMeta } from "../registry.js"; +import { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "./config-helpers.js"; +import { formatPairingApproveHint } from "./helpers.js"; +import { resolveProviderMediaMaxBytes } from "./media-limits.js"; +import { normalizeSignalMessagingTarget } from "./normalize-target.js"; +import { signalOnboardingAdapter } from "./onboarding/signal.js"; +import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; +import { + applyAccountNameToProviderSection, + migrateBaseNameToDefaultAccount, +} from "./setup-helpers.js"; +import type { ProviderPlugin } from "./types.js"; + +const meta = getChatProviderMeta("signal"); + +export const signalPlugin: ProviderPlugin = { + id: "signal", + meta: { + ...meta, + }, + onboarding: signalOnboardingAdapter, + pairing: { + idLabel: "signalNumber", + normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), + notifyApproval: async ({ id }) => { + await sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["signal"] }, + 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, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => + entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")), + ) + .filter(Boolean), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = + accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.signal?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `signal.accounts.${resolvedAccountId}.` + : "signal."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("signal"), + normalizeEntry: (raw) => + normalizeE164(raw.replace(/^signal:/i, "").trim()), + }; + }, + }, + messaging: { + normalizeTarget: normalizeSignalMessagingTarget, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToProviderSection({ + cfg, + providerKey: "signal", + accountId, + name, + }), + validateInput: ({ input }) => { + if ( + !input.signalNumber && + !input.httpUrl && + !input.httpHost && + !input.httpPort && + !input.cliPath + ) { + return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToProviderSection({ + cfg, + providerKey: "signal", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + providerKey: "signal", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + signal: { + ...next.signal, + enabled: true, + ...(input.signalNumber ? { account: input.signalNumber } : {}), + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), + ...(input.httpHost ? { httpHost: input.httpHost } : {}), + ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + }, + }; + } + return { + ...next, + signal: { + ...next.signal, + enabled: true, + accounts: { + ...next.signal?.accounts, + [accountId]: { + ...next.signal?.accounts?.[accountId], + enabled: true, + ...(input.signalNumber ? { account: input.signalNumber } : {}), + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), + ...(input.httpHost ? { httpHost: input.httpHost } : {}), + ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + }, + }, + }, + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: chunkText, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Signal requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = deps?.sendSignal ?? sendMessageSignal; + const maxBytes = resolveProviderMediaMaxBytes({ + cfg, + resolveProviderLimitMb: ({ cfg, accountId }) => + cfg.signal?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.signal?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + maxBytes, + accountId: accountId ?? undefined, + }); + return { provider: "signal", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { + const send = deps?.sendSignal ?? sendMessageSignal; + const maxBytes = resolveProviderMediaMaxBytes({ + cfg, + resolveProviderLimitMb: ({ cfg, accountId }) => + cfg.signal?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.signal?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + }); + return { provider: "signal", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = + typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) return []; + return [ + { + provider: "signal", + accountId: account.accountId, + kind: "runtime", + message: `Provider error: ${lastError}`, + }, + ]; + }), + buildProviderSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + baseUrl: snapshot.baseUrl ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => { + const baseUrl = account.baseUrl; + return await probeSignal(baseUrl, timeoutMs); + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + baseUrl: account.baseUrl, + }); + ctx.log?.info( + `[${account.accountId}] starting provider (${account.baseUrl})`, + ); + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + const { monitorSignalProvider } = await import("../../signal/index.js"); + return monitorSignalProvider({ + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + mediaMaxMb: account.config.mediaMaxMb, + }); + }, + }, +}; diff --git a/src/providers/plugins/slack.ts b/src/providers/plugins/slack.ts new file mode 100644 index 000000000..544de2fc4 --- /dev/null +++ b/src/providers/plugins/slack.ts @@ -0,0 +1,505 @@ +import { + createActionGate, + readNumberParam, + readStringParam, +} from "../../agents/tools/common.js"; +import { handleSlackAction } from "../../agents/tools/slack-actions.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../routing/session-key.js"; +import { + listEnabledSlackAccounts, + listSlackAccountIds, + type ResolvedSlackAccount, + resolveDefaultSlackAccountId, + resolveSlackAccount, +} from "../../slack/accounts.js"; +import { probeSlack } from "../../slack/probe.js"; +import { sendMessageSlack } from "../../slack/send.js"; +import { getChatProviderMeta } from "../registry.js"; +import { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "./config-helpers.js"; +import { resolveSlackGroupRequireMention } from "./group-mentions.js"; +import { formatPairingApproveHint } from "./helpers.js"; +import { normalizeSlackMessagingTarget } from "./normalize-target.js"; +import { slackOnboardingAdapter } from "./onboarding/slack.js"; +import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; +import { + applyAccountNameToProviderSection, + migrateBaseNameToDefaultAccount, +} from "./setup-helpers.js"; +import type { ProviderMessageActionName, ProviderPlugin } from "./types.js"; + +const meta = getChatProviderMeta("slack"); + +export const slackPlugin: ProviderPlugin = { + id: "slack", + meta: { + ...meta, + }, + onboarding: slackOnboardingAdapter, + pairing: { + idLabel: "slackUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), + notifyApproval: async ({ id }) => { + await sendMessageSlack(`user:${id}`, PAIRING_APPROVED_MESSAGE); + }, + }, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["slack"] }, + config: { + listAccountIds: (cfg) => listSlackAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "slack", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "slack", + accountId, + clearBaseFields: ["botToken", "appToken", "name"], + }), + isConfigured: (account) => Boolean(account.botToken && account.appToken), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.botToken && account.appToken), + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveSlackAccount({ cfg, accountId }).dm?.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = + accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.slack?.accounts?.[resolvedAccountId]); + const allowFromPath = useAccountPath + ? `slack.accounts.${resolvedAccountId}.dm.` + : "slack.dm."; + return { + policy: account.dm?.policy ?? "pairing", + allowFrom: account.dm?.allowFrom ?? [], + allowFromPath, + approveHint: formatPairingApproveHint("slack"), + normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""), + }; + }, + }, + groups: { + resolveRequireMention: resolveSlackGroupRequireMention, + }, + threading: { + resolveReplyToMode: ({ cfg, accountId }) => + resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", + allowTagsWhenOff: true, + buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => { + const configuredReplyToMode = + resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off"; + const effectiveReplyToMode = context.ThreadLabel + ? "all" + : configuredReplyToMode; + return { + currentChannelId: context.To?.startsWith("channel:") + ? context.To.slice("channel:".length) + : undefined, + currentThreadTs: context.ReplyToId, + replyToMode: effectiveReplyToMode, + hasRepliedRef, + }; + }, + }, + messaging: { + normalizeTarget: normalizeSlackMessagingTarget, + }, + actions: { + listActions: ({ cfg }) => { + const accounts = listEnabledSlackAccounts(cfg).filter( + (account) => account.botTokenSource !== "none", + ); + if (accounts.length === 0) return []; + const isActionEnabled = (key: string, defaultValue = true) => { + for (const account of accounts) { + const gate = createActionGate( + (account.actions ?? cfg.slack?.actions) as Record< + string, + boolean | undefined + >, + ); + if (gate(key, defaultValue)) return true; + } + return false; + }; + + const actions = new Set(["send"]); + if (isActionEnabled("reactions")) { + actions.add("react"); + actions.add("reactions"); + } + if (isActionEnabled("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (isActionEnabled("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (isActionEnabled("memberInfo")) actions.add("member-info"); + if (isActionEnabled("emojiList")) actions.add("emoji-list"); + return Array.from(actions); + }, + extractToolSend: ({ args }) => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action !== "sendMessage") return null; + const to = typeof args.to === "string" ? args.to : undefined; + if (!to) return null; + const accountId = + typeof args.accountId === "string" ? args.accountId.trim() : undefined; + return { to, accountId }; + }, + handleAction: async ({ action, params, cfg, accountId, toolContext }) => { + const resolveChannelId = () => + readStringParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "message", { + required: true, + allowEmpty: true, + }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const threadId = readStringParam(params, "threadId"); + const replyTo = readStringParam(params, "replyTo"); + return await handleSlackAction( + { + action: "sendMessage", + to, + content, + mediaUrl: mediaUrl ?? undefined, + accountId: accountId ?? undefined, + threadTs: threadId ?? replyTo ?? undefined, + }, + cfg, + toolContext, + ); + } + + if (action === "react") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = + typeof params.remove === "boolean" ? params.remove : undefined; + return await handleSlackAction( + { + action: "react", + channelId: resolveChannelId(), + messageId, + emoji, + remove, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleSlackAction( + { + action: "reactions", + channelId: resolveChannelId(), + messageId, + limit, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "read") { + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleSlackAction( + { + action: "readMessages", + channelId: resolveChannelId(), + limit, + before: readStringParam(params, "before"), + after: readStringParam(params, "after"), + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const content = readStringParam(params, "message", { required: true }); + return await handleSlackAction( + { + action: "editMessage", + channelId: resolveChannelId(), + messageId, + content, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { + required: true, + }); + return await handleSlackAction( + { + action: "deleteMessage", + channelId: resolveChannelId(), + messageId, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" + ? undefined + : readStringParam(params, "messageId", { required: true }); + return await handleSlackAction( + { + action: + action === "pin" + ? "pinMessage" + : action === "unpin" + ? "unpinMessage" + : "listPins", + channelId: resolveChannelId(), + messageId, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "member-info") { + const userId = readStringParam(params, "userId", { required: true }); + return await handleSlackAction( + { action: "memberInfo", userId, accountId: accountId ?? undefined }, + cfg, + ); + } + + if (action === "emoji-list") { + return await handleSlackAction( + { action: "emojiList", accountId: accountId ?? undefined }, + cfg, + ); + } + + throw new Error( + `Action ${action} is not supported for provider ${meta.id}.`, + ); + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToProviderSection({ + cfg, + providerKey: "slack", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Slack env tokens can only be used for the default account."; + } + if (!input.useEnv && (!input.botToken || !input.appToken)) { + return "Slack requires --bot-token and --app-token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToProviderSection({ + cfg, + providerKey: "slack", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + providerKey: "slack", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + slack: { + ...next.slack, + enabled: true, + ...(input.useEnv + ? {} + : { + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }), + }, + }; + } + return { + ...next, + slack: { + ...next.slack, + enabled: true, + accounts: { + ...next.slack?.accounts, + [accountId]: { + ...next.slack?.accounts?.[accountId], + enabled: true, + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), + }, + }, + }, + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: null, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Slack requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ to, text, accountId, deps, replyToId }) => { + const send = deps?.sendSlack ?? sendMessageSlack; + const result = await send(to, text, { + threadTs: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { provider: "slack", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { + const send = deps?.sendSlack ?? sendMessageSlack; + const result = await send(to, text, { + mediaUrl, + threadTs: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { provider: "slack", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildProviderSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + botTokenSource: snapshot.botTokenSource ?? "none", + appTokenSource: snapshot.appTokenSource ?? "none", + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => { + const token = account.botToken?.trim(); + if (!token) return { ok: false, error: "missing token" }; + return await probeSlack(token, timeoutMs); + }, + buildAccountSnapshot: ({ account, runtime, probe }) => { + const configured = Boolean(account.botToken && account.appToken); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const botToken = account.botToken?.trim(); + const appToken = account.appToken?.trim(); + ctx.log?.info(`[${account.accountId}] starting provider`); + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + const { monitorSlackProvider } = await import("../../slack/index.js"); + return monitorSlackProvider({ + botToken: botToken ?? "", + appToken: appToken ?? "", + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + mediaMaxMb: account.config.mediaMaxMb, + slashCommand: account.config.slashCommand, + }); + }, + }, +}; diff --git a/src/providers/plugins/status-issues/discord.ts b/src/providers/plugins/status-issues/discord.ts new file mode 100644 index 000000000..f4ceb2495 --- /dev/null +++ b/src/providers/plugins/status-issues/discord.ts @@ -0,0 +1,145 @@ +import type { ProviderAccountSnapshot, ProviderStatusIssue } from "../types.js"; +import { asString, isRecord } from "./shared.js"; + +type DiscordIntentSummary = { + messageContent?: "enabled" | "limited" | "disabled"; +}; + +type DiscordApplicationSummary = { + intents?: DiscordIntentSummary; +}; + +type DiscordAccountStatus = { + accountId?: unknown; + enabled?: unknown; + configured?: unknown; + application?: unknown; + audit?: unknown; +}; + +type DiscordPermissionsAuditSummary = { + unresolvedChannels?: number; + channels?: Array<{ + channelId: string; + ok?: boolean; + missing?: string[]; + error?: string | null; + }>; +}; + +function readDiscordAccountStatus( + value: ProviderAccountSnapshot, +): DiscordAccountStatus | null { + if (!isRecord(value)) return null; + return { + accountId: value.accountId, + enabled: value.enabled, + configured: value.configured, + application: value.application, + audit: value.audit, + }; +} + +function readDiscordApplicationSummary( + value: unknown, +): DiscordApplicationSummary { + if (!isRecord(value)) return {}; + const intentsRaw = value.intents; + if (!isRecord(intentsRaw)) return {}; + return { + intents: { + messageContent: + intentsRaw.messageContent === "enabled" || + intentsRaw.messageContent === "limited" || + intentsRaw.messageContent === "disabled" + ? intentsRaw.messageContent + : undefined, + }, + }; +} + +function readDiscordPermissionsAuditSummary( + value: unknown, +): DiscordPermissionsAuditSummary { + if (!isRecord(value)) return {}; + const unresolvedChannels = + typeof value.unresolvedChannels === "number" && + Number.isFinite(value.unresolvedChannels) + ? value.unresolvedChannels + : undefined; + const channelsRaw = value.channels; + const channels = Array.isArray(channelsRaw) + ? (channelsRaw + .map((entry) => { + if (!isRecord(entry)) return null; + const channelId = asString(entry.channelId); + if (!channelId) return null; + const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; + const missing = Array.isArray(entry.missing) + ? entry.missing.map((v) => asString(v)).filter(Boolean) + : undefined; + const error = asString(entry.error) ?? null; + return { + channelId, + ok, + missing: missing?.length ? missing : undefined, + error, + }; + }) + .filter(Boolean) as DiscordPermissionsAuditSummary["channels"]) + : undefined; + return { unresolvedChannels, channels }; +} + +export function collectDiscordStatusIssues( + accounts: ProviderAccountSnapshot[], +): ProviderStatusIssue[] { + const issues: ProviderStatusIssue[] = []; + for (const entry of accounts) { + const account = readDiscordAccountStatus(entry); + if (!account) continue; + const accountId = asString(account.accountId) ?? "default"; + const enabled = account.enabled !== false; + const configured = account.configured === true; + if (!enabled || !configured) continue; + + const app = readDiscordApplicationSummary(account.application); + const messageContent = app.intents?.messageContent; + if (messageContent === "disabled") { + issues.push({ + provider: "discord", + accountId, + kind: "intent", + message: + "Message Content Intent is disabled. Bot may not see normal channel messages.", + fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.", + }); + } + + const audit = readDiscordPermissionsAuditSummary(account.audit); + if (audit.unresolvedChannels && audit.unresolvedChannels > 0) { + issues.push({ + provider: "discord", + accountId, + kind: "config", + message: `Some configured guild channels are not numeric IDs (unresolvedChannels=${audit.unresolvedChannels}). Permission audit can only check numeric channel IDs.`, + fix: "Use numeric channel IDs as keys in discord.guilds.*.channels (then rerun providers status --probe).", + }); + } + for (const channel of audit.channels ?? []) { + if (channel.ok === true) continue; + const missing = channel.missing?.length + ? ` missing ${channel.missing.join(", ")}` + : ""; + const error = channel.error ? `: ${channel.error}` : ""; + issues.push({ + provider: "discord", + accountId, + kind: "permissions", + message: `Channel ${channel.channelId} permission check failed.${missing}${error}`, + fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).", + }); + } + } + return issues; +} diff --git a/src/providers/plugins/status-issues/shared.ts b/src/providers/plugins/status-issues/shared.ts new file mode 100644 index 000000000..c0d187966 --- /dev/null +++ b/src/providers/plugins/status-issues/shared.ts @@ -0,0 +1,9 @@ +export function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +export function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/providers/plugins/status-issues/telegram.ts b/src/providers/plugins/status-issues/telegram.ts new file mode 100644 index 000000000..ca572c140 --- /dev/null +++ b/src/providers/plugins/status-issues/telegram.ts @@ -0,0 +1,123 @@ +import type { ProviderAccountSnapshot, ProviderStatusIssue } from "../types.js"; +import { asString, isRecord } from "./shared.js"; + +type TelegramAccountStatus = { + accountId?: unknown; + enabled?: unknown; + configured?: unknown; + allowUnmentionedGroups?: unknown; + audit?: unknown; +}; + +type TelegramGroupMembershipAuditSummary = { + unresolvedGroups?: number; + hasWildcardUnmentionedGroups?: boolean; + groups?: Array<{ + chatId: string; + ok?: boolean; + status?: string | null; + error?: string | null; + }>; +}; + +function readTelegramAccountStatus( + value: ProviderAccountSnapshot, +): TelegramAccountStatus | null { + if (!isRecord(value)) return null; + return { + accountId: value.accountId, + enabled: value.enabled, + configured: value.configured, + allowUnmentionedGroups: value.allowUnmentionedGroups, + audit: value.audit, + }; +} + +function readTelegramGroupMembershipAuditSummary( + value: unknown, +): TelegramGroupMembershipAuditSummary { + if (!isRecord(value)) return {}; + const unresolvedGroups = + typeof value.unresolvedGroups === "number" && + Number.isFinite(value.unresolvedGroups) + ? value.unresolvedGroups + : undefined; + const hasWildcardUnmentionedGroups = + typeof value.hasWildcardUnmentionedGroups === "boolean" + ? value.hasWildcardUnmentionedGroups + : undefined; + const groupsRaw = value.groups; + const groups = Array.isArray(groupsRaw) + ? (groupsRaw + .map((entry) => { + if (!isRecord(entry)) return null; + const chatId = asString(entry.chatId); + if (!chatId) return null; + const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; + const status = asString(entry.status) ?? null; + const error = asString(entry.error) ?? null; + return { chatId, ok, status, error }; + }) + .filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"]) + : undefined; + return { unresolvedGroups, hasWildcardUnmentionedGroups, groups }; +} + +export function collectTelegramStatusIssues( + accounts: ProviderAccountSnapshot[], +): ProviderStatusIssue[] { + const issues: ProviderStatusIssue[] = []; + for (const entry of accounts) { + const account = readTelegramAccountStatus(entry); + if (!account) continue; + const accountId = asString(account.accountId) ?? "default"; + const enabled = account.enabled !== false; + const configured = account.configured === true; + if (!enabled || !configured) continue; + + if (account.allowUnmentionedGroups === true) { + issues.push({ + provider: "telegram", + accountId, + kind: "config", + message: + "Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.", + fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).", + }); + } + + const audit = readTelegramGroupMembershipAuditSummary(account.audit); + if (audit.hasWildcardUnmentionedGroups === true) { + issues.push({ + provider: "telegram", + accountId, + kind: "config", + message: + 'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.', + fix: "Add explicit numeric group ids under telegram.groups (or per-account groups) to enable probing.", + }); + } + if (audit.unresolvedGroups && audit.unresolvedGroups > 0) { + issues.push({ + provider: "telegram", + accountId, + kind: "config", + message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`, + fix: "Use numeric chat IDs (e.g. -100...) as keys in telegram.groups for requireMention=false groups.", + }); + } + for (const group of audit.groups ?? []) { + if (group.ok === true) continue; + const status = group.status ? ` status=${group.status}` : ""; + const err = group.error ? `: ${group.error}` : ""; + issues.push({ + provider: "telegram", + accountId, + kind: "runtime", + message: `Group ${group.chatId} not reachable by bot.${status}${err}`, + fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.", + }); + } + } + return issues; +} diff --git a/src/providers/plugins/status-issues/whatsapp.ts b/src/providers/plugins/status-issues/whatsapp.ts new file mode 100644 index 000000000..68c69411b --- /dev/null +++ b/src/providers/plugins/status-issues/whatsapp.ts @@ -0,0 +1,70 @@ +import type { ProviderAccountSnapshot, ProviderStatusIssue } from "../types.js"; +import { asString, isRecord } from "./shared.js"; + +type WhatsAppAccountStatus = { + accountId?: unknown; + enabled?: unknown; + linked?: unknown; + connected?: unknown; + running?: unknown; + reconnectAttempts?: unknown; + lastError?: unknown; +}; + +function readWhatsAppAccountStatus( + value: ProviderAccountSnapshot, +): WhatsAppAccountStatus | null { + if (!isRecord(value)) return null; + return { + accountId: value.accountId, + enabled: value.enabled, + linked: value.linked, + connected: value.connected, + running: value.running, + reconnectAttempts: value.reconnectAttempts, + lastError: value.lastError, + }; +} + +export function collectWhatsAppStatusIssues( + accounts: ProviderAccountSnapshot[], +): ProviderStatusIssue[] { + const issues: ProviderStatusIssue[] = []; + for (const entry of accounts) { + const account = readWhatsAppAccountStatus(entry); + if (!account) continue; + const accountId = asString(account.accountId) ?? "default"; + const enabled = account.enabled !== false; + if (!enabled) continue; + const linked = account.linked === true; + const running = account.running === true; + const connected = account.connected === true; + const reconnectAttempts = + typeof account.reconnectAttempts === "number" + ? account.reconnectAttempts + : null; + const lastError = asString(account.lastError); + + if (!linked) { + issues.push({ + provider: "whatsapp", + accountId, + kind: "auth", + message: "Not linked (no WhatsApp Web session).", + fix: "Run: clawdbot providers login (scan QR on the gateway host).", + }); + continue; + } + + if (running && !connected) { + issues.push({ + provider: "whatsapp", + accountId, + kind: "runtime", + message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`, + fix: "Run: clawdbot doctor (or restart the gateway). If it persists, relink via providers login and check logs.", + }); + } + } + return issues; +} diff --git a/src/providers/plugins/status.ts b/src/providers/plugins/status.ts new file mode 100644 index 000000000..859fb5553 --- /dev/null +++ b/src/providers/plugins/status.ts @@ -0,0 +1,39 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import type { ProviderAccountSnapshot, ProviderPlugin } from "./types.js"; + +// Provider docking: status snapshots flow through plugin.status hooks here. +export async function buildProviderAccountSnapshot(params: { + plugin: ProviderPlugin; + cfg: ClawdbotConfig; + accountId: string; + runtime?: ProviderAccountSnapshot; + probe?: unknown; + audit?: unknown; +}): Promise { + const account = params.plugin.config.resolveAccount( + params.cfg, + params.accountId, + ); + if (params.plugin.status?.buildAccountSnapshot) { + return await params.plugin.status.buildAccountSnapshot({ + account, + cfg: params.cfg, + runtime: params.runtime, + probe: params.probe, + audit: params.audit, + }); + } + const enabled = params.plugin.config.isEnabled + ? params.plugin.config.isEnabled(account, params.cfg) + : account && typeof account === "object" + ? (account as { enabled?: boolean }).enabled + : undefined; + const configured = params.plugin.config.isConfigured + ? await params.plugin.config.isConfigured(account, params.cfg) + : undefined; + return { + accountId: params.accountId, + enabled, + configured, + }; +} diff --git a/src/providers/plugins/telegram.ts b/src/providers/plugins/telegram.ts new file mode 100644 index 000000000..eec4e9ee6 --- /dev/null +++ b/src/providers/plugins/telegram.ts @@ -0,0 +1,465 @@ +import { chunkMarkdownText } from "../../auto-reply/chunk.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { writeConfigFile } from "../../config/config.js"; +import { shouldLogVerbose } from "../../globals.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../routing/session-key.js"; +import { + listTelegramAccountIds, + type ResolvedTelegramAccount, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "../../telegram/accounts.js"; +import { + auditTelegramGroupMembership, + collectTelegramUnmentionedGroupIds, +} from "../../telegram/audit.js"; +import { probeTelegram } from "../../telegram/probe.js"; +import { sendMessageTelegram } from "../../telegram/send.js"; +import { resolveTelegramToken } from "../../telegram/token.js"; +import { getChatProviderMeta } from "../registry.js"; +import { telegramMessageActions } from "./actions/telegram.js"; +import { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "./config-helpers.js"; +import { resolveTelegramGroupRequireMention } from "./group-mentions.js"; +import { formatPairingApproveHint } from "./helpers.js"; +import { normalizeTelegramMessagingTarget } from "./normalize-target.js"; +import { telegramOnboardingAdapter } from "./onboarding/telegram.js"; +import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js"; +import { + applyAccountNameToProviderSection, + migrateBaseNameToDefaultAccount, +} from "./setup-helpers.js"; +import { collectTelegramStatusIssues } from "./status-issues/telegram.js"; +import type { ProviderPlugin } from "./types.js"; + +const meta = getChatProviderMeta("telegram"); + +export const telegramPlugin: ProviderPlugin = { + id: "telegram", + meta: { + ...meta, + quickstartAllowFrom: true, + }, + onboarding: telegramOnboardingAdapter, + pairing: { + idLabel: "telegramUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), + notifyApproval: async ({ cfg, id }) => { + const { token } = resolveTelegramToken(cfg); + if (!token) throw new Error("telegram token not configured"); + await sendMessageTelegram(id, PAIRING_APPROVED_MESSAGE, { token }); + }, + }, + capabilities: { + chatTypes: ["direct", "group", "channel", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["telegram"] }, + config: { + listAccountIds: (cfg) => listTelegramAccountIds(cfg), + resolveAccount: (cfg, accountId) => + resolveTelegramAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "telegram", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "telegram", + accountId, + clearBaseFields: ["botToken", "tokenFile", "name"], + }), + 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, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^(telegram|tg):/i, "")) + .map((entry) => entry.toLowerCase()), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = + accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + cfg.telegram?.accounts?.[resolvedAccountId], + ); + const basePath = useAccountPath + ? `telegram.accounts.${resolvedAccountId}.` + : "telegram."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("telegram"), + normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), + }; + }, + collectWarnings: ({ account }) => { + const groupPolicy = account.config.groupPolicy ?? "open"; + const groupAllowlistConfigured = + account.config.groups && Object.keys(account.config.groups).length > 0; + if (groupPolicy !== "open" || groupAllowlistConfigured) return []; + return [ + `- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`, + ]; + }, + }, + groups: { + resolveRequireMention: resolveTelegramGroupRequireMention, + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.telegram?.replyToMode ?? "first", + }, + messaging: { + normalizeTarget: normalizeTelegramMessagingTarget, + }, + actions: telegramMessageActions, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToProviderSection({ + cfg, + providerKey: "telegram", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "TELEGRAM_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Telegram requires --token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToProviderSection({ + cfg, + providerKey: "telegram", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + providerKey: "telegram", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + telegram: { + ...next.telegram, + enabled: true, + ...(input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }; + } + return { + ...next, + telegram: { + ...next.telegram, + enabled: true, + accounts: { + ...next.telegram?.accounts, + [accountId]: { + ...next.telegram?.accounts?.[accountId], + enabled: true, + ...(input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }, + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: chunkMarkdownText, + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error("Delivering to Telegram requires --to "), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { + const send = deps?.sendTelegram ?? sendMessageTelegram; + const replyToMessageId = replyToId + ? Number.parseInt(replyToId, 10) + : undefined; + const resolvedReplyToMessageId = Number.isFinite(replyToMessageId) + ? replyToMessageId + : undefined; + const result = await send(to, text, { + verbose: false, + messageThreadId: threadId ?? undefined, + replyToMessageId: resolvedReplyToMessageId, + accountId: accountId ?? undefined, + }); + return { provider: "telegram", ...result }; + }, + sendMedia: async ({ + to, + text, + mediaUrl, + accountId, + deps, + replyToId, + threadId, + }) => { + const send = deps?.sendTelegram ?? sendMessageTelegram; + const replyToMessageId = replyToId + ? Number.parseInt(replyToId, 10) + : undefined; + const resolvedReplyToMessageId = Number.isFinite(replyToMessageId) + ? replyToMessageId + : undefined; + const result = await send(to, text, { + verbose: false, + mediaUrl, + messageThreadId: threadId ?? undefined, + replyToMessageId: resolvedReplyToMessageId, + accountId: accountId ?? undefined, + }); + return { provider: "telegram", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: collectTelegramStatusIssues, + buildProviderSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + running: snapshot.running ?? false, + mode: snapshot.mode ?? null, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + probeTelegram(account.token, timeoutMs, account.config.proxy), + auditAccount: async ({ account, timeoutMs, probe, cfg }) => { + const groups = + cfg.telegram?.accounts?.[account.accountId]?.groups ?? + cfg.telegram?.groups; + const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } = + collectTelegramUnmentionedGroupIds(groups); + if ( + !groupIds.length && + unresolvedGroups === 0 && + !hasWildcardUnmentionedGroups + ) { + return undefined; + } + const botId = + (probe as { ok?: boolean; bot?: { id?: number } })?.ok && + (probe as { bot?: { id?: number } }).bot?.id != null + ? (probe as { bot: { id: number } }).bot.id + : null; + if (!botId) { + return { + ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups, + checkedGroups: 0, + unresolvedGroups, + hasWildcardUnmentionedGroups, + groups: [], + elapsedMs: 0, + }; + } + const audit = await auditTelegramGroupMembership({ + token: account.token, + botId, + groupIds, + proxyUrl: account.config.proxy, + timeoutMs, + }); + return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups }; + }, + buildAccountSnapshot: ({ account, cfg, runtime, probe, audit }) => { + const configured = Boolean(account.token?.trim()); + const groups = + cfg.telegram?.accounts?.[account.accountId]?.groups ?? + cfg.telegram?.groups; + const allowUnmentionedGroups = + Boolean( + groups?.["*"] && + (groups["*"] as { requireMention?: boolean }).requireMention === + false, + ) || + Object.entries(groups ?? {}).some( + ([key, value]) => + key !== "*" && + Boolean(value) && + typeof value === "object" && + (value as { requireMention?: boolean }).requireMention === false, + ); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + mode: + runtime?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), + probe, + audit, + allowUnmentionedGroups, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const token = account.token.trim(); + let telegramBotLabel = ""; + try { + const probe = await probeTelegram(token, 2500, account.config.proxy); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) telegramBotLabel = ` (@${username})`; + } catch (err) { + if (shouldLogVerbose()) { + ctx.log?.debug?.( + `[${account.accountId}] bot probe failed: ${String(err)}`, + ); + } + } + ctx.log?.info( + `[${account.accountId}] starting provider${telegramBotLabel}`, + ); + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + const { monitorTelegramProvider } = await import( + "../../telegram/monitor.js" + ); + return monitorTelegramProvider({ + token, + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + useWebhook: Boolean(account.config.webhookUrl), + webhookUrl: account.config.webhookUrl, + webhookSecret: account.config.webhookSecret, + webhookPath: account.config.webhookPath, + }); + }, + logoutAccount: async ({ accountId, cfg }) => { + const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; + const nextCfg = { ...cfg } as ClawdbotConfig; + const nextTelegram = cfg.telegram ? { ...cfg.telegram } : undefined; + let cleared = false; + let changed = false; + if (nextTelegram) { + if (accountId === DEFAULT_ACCOUNT_ID && nextTelegram.botToken) { + delete nextTelegram.botToken; + cleared = true; + changed = true; + } + const accounts = + nextTelegram.accounts && typeof nextTelegram.accounts === "object" + ? { ...nextTelegram.accounts } + : undefined; + if (accounts && accountId in accounts) { + const entry = accounts[accountId]; + if (entry && typeof entry === "object") { + const nextEntry = { ...entry } as Record; + if ("botToken" in nextEntry) { + const token = nextEntry.botToken; + if (typeof token === "string" ? token.trim() : token) { + cleared = true; + } + delete nextEntry.botToken; + changed = true; + } + if (Object.keys(nextEntry).length === 0) { + delete accounts[accountId]; + changed = true; + } else { + accounts[accountId] = nextEntry as typeof entry; + } + } + } + if (accounts) { + if (Object.keys(accounts).length === 0) { + delete nextTelegram.accounts; + changed = true; + } else { + nextTelegram.accounts = accounts; + } + } + } + if (changed) { + if (nextTelegram && Object.keys(nextTelegram).length > 0) { + nextCfg.telegram = nextTelegram; + } else { + delete nextCfg.telegram; + } + } + const resolved = resolveTelegramAccount({ + cfg: changed ? nextCfg : cfg, + accountId, + }); + const loggedOut = resolved.tokenSource === "none"; + if (changed) { + await writeConfigFile(nextCfg); + } + return { cleared, envToken: Boolean(envToken), loggedOut }; + }, + }, +}; diff --git a/src/providers/plugins/types.ts b/src/providers/plugins/types.ts new file mode 100644 index 000000000..f6fd550c9 --- /dev/null +++ b/src/providers/plugins/types.ts @@ -0,0 +1,580 @@ +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { TSchema } from "@sinclair/typebox"; +import type { MsgContext } from "../../auto-reply/templating.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { + OutboundDeliveryResult, + OutboundSendDeps, +} from "../../infra/outbound/deliver.js"; +import type { PollInput } from "../../polls.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import type { + GatewayClientMode, + GatewayClientName, +} from "../../utils/message-provider.js"; +import type { ChatProviderId } from "../registry.js"; +import type { ProviderOnboardingAdapter } from "./onboarding-types.js"; + +export type ProviderId = ChatProviderId; + +export type ProviderOutboundTargetMode = "explicit" | "implicit" | "heartbeat"; + +export type ProviderAgentTool = AgentTool; + +export type ProviderAgentToolFactory = (params: { + cfg?: ClawdbotConfig; +}) => ProviderAgentTool[]; + +export type ProviderSetupInput = { + name?: string; + token?: string; + tokenFile?: string; + botToken?: string; + appToken?: string; + signalNumber?: string; + cliPath?: string; + dbPath?: string; + service?: "imessage" | "sms" | "auto"; + region?: string; + authDir?: string; + httpUrl?: string; + httpHost?: string; + httpPort?: string; + useEnv?: boolean; +}; + +export type ProviderStatusIssue = { + provider: ProviderId; + accountId: string; + kind: "intent" | "permissions" | "config" | "auth" | "runtime"; + message: string; + fix?: string; +}; + +export type ProviderAccountState = + | "linked" + | "not linked" + | "configured" + | "not configured" + | "enabled" + | "disabled"; + +export type ProviderSetupAdapter = { + resolveAccountId?: (params: { + cfg: ClawdbotConfig; + accountId?: string; + }) => string; + applyAccountName?: (params: { + cfg: ClawdbotConfig; + accountId: string; + name?: string; + }) => ClawdbotConfig; + applyAccountConfig: (params: { + cfg: ClawdbotConfig; + accountId: string; + input: ProviderSetupInput; + }) => ClawdbotConfig; + validateInput?: (params: { + cfg: ClawdbotConfig; + accountId: string; + input: ProviderSetupInput; + }) => string | null; +}; + +export type ProviderHeartbeatDeps = { + webAuthExists?: () => Promise; + hasActiveWebListener?: () => boolean; +}; + +export type ProviderMeta = { + id: ProviderId; + label: string; + selectionLabel: string; + docsPath: string; + docsLabel?: string; + blurb: string; + order?: number; + showConfigured?: boolean; + quickstartAllowFrom?: boolean; + forceAccountBinding?: boolean; + preferSessionLookupForAnnounceTarget?: boolean; +}; + +export type ProviderAccountSnapshot = { + accountId: string; + name?: string; + enabled?: boolean; + configured?: boolean; + linked?: boolean; + running?: boolean; + connected?: boolean; + reconnectAttempts?: number; + lastConnectedAt?: number | null; + lastDisconnect?: + | string + | { + at: number; + status?: number; + error?: string; + loggedOut?: boolean; + } + | null; + lastMessageAt?: number | null; + lastEventAt?: number | null; + lastError?: string | null; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastInboundAt?: number | null; + lastOutboundAt?: number | null; + mode?: string; + dmPolicy?: string; + allowFrom?: string[]; + tokenSource?: string; + botTokenSource?: string; + appTokenSource?: string; + baseUrl?: string; + allowUnmentionedGroups?: boolean; + cliPath?: string | null; + dbPath?: string | null; + port?: number | null; + probe?: unknown; + lastProbeAt?: number | null; + audit?: unknown; + application?: unknown; + bot?: unknown; +}; + +export type ProviderLogSink = { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; +}; + +export type ProviderConfigAdapter = { + listAccountIds: (cfg: ClawdbotConfig) => string[]; + resolveAccount: ( + cfg: ClawdbotConfig, + accountId?: string | null, + ) => ResolvedAccount; + defaultAccountId?: (cfg: ClawdbotConfig) => string; + setAccountEnabled?: (params: { + cfg: ClawdbotConfig; + accountId: string; + enabled: boolean; + }) => ClawdbotConfig; + deleteAccount?: (params: { + cfg: ClawdbotConfig; + accountId: string; + }) => ClawdbotConfig; + isEnabled?: (account: ResolvedAccount, cfg: ClawdbotConfig) => boolean; + disabledReason?: (account: ResolvedAccount, cfg: ClawdbotConfig) => string; + isConfigured?: ( + account: ResolvedAccount, + cfg: ClawdbotConfig, + ) => boolean | Promise; + unconfiguredReason?: ( + account: ResolvedAccount, + cfg: ClawdbotConfig, + ) => string; + describeAccount?: ( + account: ResolvedAccount, + cfg: ClawdbotConfig, + ) => ProviderAccountSnapshot; + resolveAllowFrom?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + }) => string[] | undefined; + formatAllowFrom?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + allowFrom: Array; + }) => string[]; +}; + +export type ProviderGroupContext = { + cfg: ClawdbotConfig; + groupId?: string | null; + groupRoom?: string | null; + groupSpace?: string | null; + accountId?: string | null; +}; + +export type ProviderGroupAdapter = { + resolveRequireMention?: (params: ProviderGroupContext) => boolean | undefined; + resolveGroupIntroHint?: (params: ProviderGroupContext) => string | undefined; +}; + +export type ProviderOutboundContext = { + cfg: ClawdbotConfig; + to: string; + text: string; + mediaUrl?: string; + gifPlayback?: boolean; + replyToId?: string | null; + threadId?: number | null; + accountId?: string | null; + deps?: OutboundSendDeps; +}; + +export type ProviderPollResult = { + messageId: string; + toJid?: string; + channelId?: string; + conversationId?: string; + pollId?: string; +}; + +export type ProviderPollContext = { + cfg: ClawdbotConfig; + to: string; + poll: PollInput; + accountId?: string | null; +}; + +export type ProviderOutboundAdapter = { + deliveryMode: "direct" | "gateway" | "hybrid"; + chunker?: ((text: string, limit: number) => string[]) | null; + textChunkLimit?: number; + pollMaxOptions?: number; + resolveTarget?: (params: { + cfg?: ClawdbotConfig; + to?: string; + allowFrom?: string[]; + accountId?: string | null; + mode?: ProviderOutboundTargetMode; + }) => { ok: true; to: string } | { ok: false; error: Error }; + sendText?: (ctx: ProviderOutboundContext) => Promise; + sendMedia?: (ctx: ProviderOutboundContext) => Promise; + sendPoll?: (ctx: ProviderPollContext) => Promise; +}; + +export type ProviderStatusAdapter = { + defaultRuntime?: ProviderAccountSnapshot; + buildProviderSummary?: (params: { + account: ResolvedAccount; + cfg: ClawdbotConfig; + defaultAccountId: string; + snapshot: ProviderAccountSnapshot; + }) => Record | Promise>; + probeAccount?: (params: { + account: ResolvedAccount; + timeoutMs: number; + cfg: ClawdbotConfig; + }) => Promise; + auditAccount?: (params: { + account: ResolvedAccount; + timeoutMs: number; + cfg: ClawdbotConfig; + probe?: unknown; + }) => Promise; + buildAccountSnapshot?: (params: { + account: ResolvedAccount; + cfg: ClawdbotConfig; + runtime?: ProviderAccountSnapshot; + probe?: unknown; + audit?: unknown; + }) => ProviderAccountSnapshot | Promise; + logSelfId?: (params: { + account: ResolvedAccount; + cfg: ClawdbotConfig; + runtime: RuntimeEnv; + includeProviderPrefix?: boolean; + }) => void; + resolveAccountState?: (params: { + account: ResolvedAccount; + cfg: ClawdbotConfig; + configured: boolean; + enabled: boolean; + }) => ProviderAccountState; + collectStatusIssues?: ( + accounts: ProviderAccountSnapshot[], + ) => ProviderStatusIssue[]; +}; + +export type ProviderGatewayContext = { + cfg: ClawdbotConfig; + accountId: string; + account: ResolvedAccount; + runtime: RuntimeEnv; + abortSignal: AbortSignal; + log?: ProviderLogSink; + getStatus: () => ProviderAccountSnapshot; + setStatus: (next: ProviderAccountSnapshot) => void; +}; + +export type ProviderLogoutResult = { + cleared: boolean; + loggedOut?: boolean; + [key: string]: unknown; +}; + +export type ProviderLoginWithQrStartResult = { + qrDataUrl?: string; + message: string; +}; + +export type ProviderLoginWithQrWaitResult = { + connected: boolean; + message: string; +}; + +export type ProviderLogoutContext = { + cfg: ClawdbotConfig; + accountId: string; + account: ResolvedAccount; + runtime: RuntimeEnv; + log?: ProviderLogSink; +}; + +export type ProviderPairingAdapter = { + idLabel: string; + normalizeAllowEntry?: (entry: string) => string; + notifyApproval?: (params: { + cfg: ClawdbotConfig; + id: string; + runtime?: RuntimeEnv; + }) => Promise; +}; + +export type ProviderGatewayAdapter = { + startAccount?: ( + ctx: ProviderGatewayContext, + ) => Promise; + stopAccount?: (ctx: ProviderGatewayContext) => Promise; + loginWithQrStart?: (params: { + accountId?: string; + force?: boolean; + timeoutMs?: number; + verbose?: boolean; + }) => Promise; + loginWithQrWait?: (params: { + accountId?: string; + timeoutMs?: number; + }) => Promise; + logoutAccount?: ( + ctx: ProviderLogoutContext, + ) => Promise; +}; + +export type ProviderAuthAdapter = { + login?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + runtime: RuntimeEnv; + verbose?: boolean; + providerInput?: string | null; + }) => Promise; +}; + +export type ProviderHeartbeatAdapter = { + checkReady?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + deps?: ProviderHeartbeatDeps; + }) => Promise<{ ok: boolean; reason: string }>; + resolveRecipients?: (params: { + cfg: ClawdbotConfig; + opts?: { to?: string; all?: boolean }; + }) => { recipients: string[]; source: string }; +}; + +export type ProviderCapabilities = { + chatTypes: Array<"direct" | "group" | "channel" | "thread">; + polls?: boolean; + reactions?: boolean; + threads?: boolean; + media?: boolean; + nativeCommands?: boolean; + blockStreaming?: boolean; +}; + +export type ProviderElevatedAdapter = { + allowFromFallback?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + }) => Array | undefined; +}; + +export type ProviderCommandAdapter = { + enforceOwnerForCommands?: boolean; + skipWhenConfigEmpty?: boolean; +}; + +export type ProviderSecurityDmPolicy = { + policy: string; + allowFrom?: Array | null; + policyPath?: string; + allowFromPath: string; + approveHint: string; + normalizeEntry?: (raw: string) => string; +}; + +export type ProviderSecurityContext = { + cfg: ClawdbotConfig; + accountId?: string | null; + account: ResolvedAccount; +}; + +export type ProviderSecurityAdapter = { + resolveDmPolicy?: ( + ctx: ProviderSecurityContext, + ) => ProviderSecurityDmPolicy | null; + collectWarnings?: ( + ctx: ProviderSecurityContext, + ) => Promise | string[]; +}; + +export type ProviderMentionAdapter = { + stripPatterns?: (params: { + ctx: MsgContext; + cfg: ClawdbotConfig | undefined; + agentId?: string; + }) => string[]; + stripMentions?: (params: { + text: string; + ctx: MsgContext; + cfg: ClawdbotConfig | undefined; + agentId?: string; + }) => string; +}; + +export type ProviderStreamingAdapter = { + blockStreamingCoalesceDefaults?: { + minChars: number; + idleMs: number; + }; +}; + +export type ProviderThreadingAdapter = { + resolveReplyToMode?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + }) => "off" | "first" | "all"; + allowTagsWhenOff?: boolean; + buildToolContext?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + context: ProviderThreadingContext; + hasRepliedRef?: { value: boolean }; + }) => ProviderThreadingToolContext | undefined; +}; + +export type ProviderThreadingContext = { + Provider?: string; + To?: string; + ReplyToId?: string; + ThreadLabel?: string; +}; + +export type ProviderThreadingToolContext = { + currentChannelId?: string; + currentThreadTs?: string; + replyToMode?: "off" | "first" | "all"; + hasRepliedRef?: { value: boolean }; +}; + +export type ProviderMessagingAdapter = { + normalizeTarget?: (raw: string) => string | undefined; +}; + +export type ProviderMessageActionName = + | "send" + | "poll" + | "react" + | "reactions" + | "read" + | "edit" + | "delete" + | "pin" + | "unpin" + | "list-pins" + | "permissions" + | "thread-create" + | "thread-list" + | "thread-reply" + | "search" + | "sticker" + | "member-info" + | "role-info" + | "emoji-list" + | "emoji-upload" + | "sticker-upload" + | "role-add" + | "role-remove" + | "channel-info" + | "channel-list" + | "voice-status" + | "event-list" + | "event-create" + | "timeout" + | "kick" + | "ban"; + +export type ProviderMessageActionContext = { + provider: ProviderId; + action: ProviderMessageActionName; + cfg: ClawdbotConfig; + params: Record; + accountId?: string | null; + gateway?: { + url?: string; + token?: string; + timeoutMs?: number; + clientName: GatewayClientName; + clientDisplayName?: string; + mode: GatewayClientMode; + }; + toolContext?: ProviderThreadingToolContext; + dryRun?: boolean; +}; + +export type ProviderToolSend = { + to: string; + accountId?: string | null; +}; + +export type ProviderMessageActionAdapter = { + listActions?: (params: { + cfg: ClawdbotConfig; + }) => ProviderMessageActionName[]; + supportsAction?: (params: { action: ProviderMessageActionName }) => boolean; + supportsButtons?: (params: { cfg: ClawdbotConfig }) => boolean; + extractToolSend?: (params: { + args: Record; + }) => ProviderToolSend | null; + handleAction?: ( + ctx: ProviderMessageActionContext, + ) => Promise>; +}; + +// Provider docking: implement this contract in src/providers/plugins/.ts. +// biome-ignore lint/suspicious/noExplicitAny: registry aggregates heterogeneous account types. +export type ProviderPlugin = { + id: ProviderId; + meta: ProviderMeta; + capabilities: ProviderCapabilities; + reload?: { configPrefixes: string[]; noopPrefixes?: string[] }; + // CLI onboarding wizard hooks for this provider. + onboarding?: ProviderOnboardingAdapter; + config: ProviderConfigAdapter; + setup?: ProviderSetupAdapter; + pairing?: ProviderPairingAdapter; + security?: ProviderSecurityAdapter; + groups?: ProviderGroupAdapter; + mentions?: ProviderMentionAdapter; + outbound?: ProviderOutboundAdapter; + status?: ProviderStatusAdapter; + gatewayMethods?: string[]; + gateway?: ProviderGatewayAdapter; + auth?: ProviderAuthAdapter; + elevated?: ProviderElevatedAdapter; + commands?: ProviderCommandAdapter; + streaming?: ProviderStreamingAdapter; + threading?: ProviderThreadingAdapter; + messaging?: ProviderMessagingAdapter; + actions?: ProviderMessageActionAdapter; + heartbeat?: ProviderHeartbeatAdapter; + // Provider-owned agent tools (login flows, etc.). + agentTools?: ProviderAgentToolFactory | ProviderAgentTool[]; +}; diff --git a/src/providers/plugins/whatsapp-heartbeat.ts b/src/providers/plugins/whatsapp-heartbeat.ts new file mode 100644 index 000000000..843ea8903 --- /dev/null +++ b/src/providers/plugins/whatsapp-heartbeat.ts @@ -0,0 +1,77 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; +import { normalizeChatProviderId } from "../../providers/registry.js"; +import { normalizeE164 } from "../../utils.js"; + +type HeartbeatRecipientsResult = { recipients: string[]; source: string }; +type HeartbeatRecipientsOpts = { to?: string; all?: boolean }; + +function getSessionRecipients(cfg: ClawdbotConfig) { + const sessionCfg = cfg.session; + const scope = sessionCfg?.scope ?? "per-sender"; + if (scope === "global") return []; + const storePath = resolveStorePath(cfg.session?.store); + const store = loadSessionStore(storePath); + const isGroupKey = (key: string) => + key.startsWith("group:") || + key.includes(":group:") || + key.includes(":channel:") || + key.includes("@g.us"); + const isCronKey = (key: string) => key.startsWith("cron:"); + + const recipients = Object.entries(store) + .filter(([key]) => key !== "global" && key !== "unknown") + .filter(([key]) => !isGroupKey(key) && !isCronKey(key)) + .map(([_, entry]) => ({ + to: + normalizeChatProviderId(entry?.lastProvider) === "whatsapp" && + entry?.lastTo + ? normalizeE164(entry.lastTo) + : "", + updatedAt: entry?.updatedAt ?? 0, + })) + .filter(({ to }) => to.length > 1) + .sort((a, b) => b.updatedAt - a.updatedAt); + + // Dedupe while preserving recency ordering. + const seen = new Set(); + return recipients.filter((r) => { + if (seen.has(r.to)) return false; + seen.add(r.to); + return true; + }); +} + +export function resolveWhatsAppHeartbeatRecipients( + cfg: ClawdbotConfig, + opts: HeartbeatRecipientsOpts = {}, +): HeartbeatRecipientsResult { + if (opts.to) { + return { recipients: [normalizeE164(opts.to)], source: "flag" }; + } + + const sessionRecipients = getSessionRecipients(cfg); + const allowFrom = + Array.isArray(cfg.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0 + ? cfg.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164) + : []; + + const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; + + if (opts.all) { + const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]); + return { recipients: all, source: "all" }; + } + + if (sessionRecipients.length === 1) { + return { recipients: [sessionRecipients[0].to], source: "session-single" }; + } + if (sessionRecipients.length > 1) { + return { + recipients: sessionRecipients.map((s) => s.to), + source: "session-ambiguous", + }; + } + + return { recipients: allowFrom, source: "allowFrom" }; +} diff --git a/src/providers/plugins/whatsapp.ts b/src/providers/plugins/whatsapp.ts new file mode 100644 index 000000000..e1a60c39e --- /dev/null +++ b/src/providers/plugins/whatsapp.ts @@ -0,0 +1,476 @@ +import { + createActionGate, + readStringParam, +} from "../../agents/tools/common.js"; +import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js"; +import { chunkText } from "../../auto-reply/chunk.js"; +import { shouldLogVerbose } from "../../globals.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, +} from "../../routing/session-key.js"; +import { normalizeE164 } from "../../utils.js"; +import { + listWhatsAppAccountIds, + type ResolvedWhatsAppAccount, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, +} from "../../web/accounts.js"; +import { getActiveWebListener } from "../../web/active-listener.js"; +import { + getWebAuthAgeMs, + logoutWeb, + logWebSelfId, + readWebSelfId, + webAuthExists, +} from "../../web/auth-store.js"; +import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; +import { + isWhatsAppGroupJid, + normalizeWhatsAppTarget, +} from "../../whatsapp/normalize.js"; +import { getChatProviderMeta } from "../registry.js"; +import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js"; +import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js"; +import { formatPairingApproveHint } from "./helpers.js"; +import { normalizeWhatsAppMessagingTarget } from "./normalize-target.js"; +import { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js"; +import { + applyAccountNameToProviderSection, + migrateBaseNameToDefaultAccount, +} from "./setup-helpers.js"; +import { collectWhatsAppStatusIssues } from "./status-issues/whatsapp.js"; +import type { ProviderMessageActionName, ProviderPlugin } from "./types.js"; +import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js"; + +const meta = getChatProviderMeta("whatsapp"); + +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +export const whatsappPlugin: ProviderPlugin = { + id: "whatsapp", + meta: { + ...meta, + showConfigured: false, + quickstartAllowFrom: true, + forceAccountBinding: true, + preferSessionLookupForAnnounceTarget: true, + }, + onboarding: whatsappOnboardingAdapter, + agentTools: () => [createWhatsAppLoginTool()], + pairing: { + idLabel: "whatsappSenderId", + }, + capabilities: { + chatTypes: ["direct", "group"], + polls: true, + reactions: true, + media: true, + }, + reload: { configPrefixes: ["web"], noopPrefixes: ["whatsapp"] }, + gatewayMethods: ["web.login.start", "web.login.wait"], + config: { + listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), + resolveAccount: (cfg, accountId) => + resolveWhatsAppAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.whatsapp?.accounts }; + const existing = accounts[accountKey] ?? {}; + return { + ...cfg, + whatsapp: { + ...cfg.whatsapp, + accounts: { + ...accounts, + [accountKey]: { + ...existing, + enabled, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.whatsapp?.accounts }; + delete accounts[accountKey]; + return { + ...cfg, + whatsapp: { + ...cfg.whatsapp, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + }; + }, + isEnabled: (account, cfg) => + account.enabled !== false && cfg.web?.enabled !== false, + disabledReason: () => "disabled", + isConfigured: async (account) => await webAuthExists(account.authDir), + unconfiguredReason: () => "not linked", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.authDir), + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }), + resolveAllowFrom: ({ cfg, accountId }) => + resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter((entry): entry is string => Boolean(entry)) + .map((entry) => + entry === "*" ? entry : normalizeWhatsAppTarget(entry), + ) + .filter((entry): entry is string => Boolean(entry)), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = + accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + cfg.whatsapp?.accounts?.[resolvedAccountId], + ); + const basePath = useAccountPath + ? `whatsapp.accounts.${resolvedAccountId}.` + : "whatsapp."; + return { + policy: account.dmPolicy ?? "pairing", + allowFrom: account.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("whatsapp"), + normalizeEntry: (raw) => normalizeE164(raw), + }; + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToProviderSection({ + cfg, + providerKey: "whatsapp", + accountId, + name, + alwaysUseAccounts: true, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToProviderSection({ + cfg, + providerKey: "whatsapp", + accountId, + name: input.name, + alwaysUseAccounts: true, + }); + const next = migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + providerKey: "whatsapp", + alwaysUseAccounts: true, + }); + const entry = { + ...next.whatsapp?.accounts?.[accountId], + ...(input.authDir ? { authDir: input.authDir } : {}), + enabled: true, + }; + return { + ...next, + whatsapp: { + ...next.whatsapp, + accounts: { + ...next.whatsapp?.accounts, + [accountId]: entry, + }, + }, + }; + }, + }, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveGroupIntroHint: () => + "WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).", + }, + mentions: { + stripPatterns: ({ ctx }) => { + const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, ""); + if (!selfE164) return []; + const escaped = escapeRegExp(selfE164); + return [escaped, `@${escaped}`]; + }, + }, + commands: { + enforceOwnerForCommands: true, + skipWhenConfigEmpty: true, + }, + messaging: { + normalizeTarget: normalizeWhatsAppMessagingTarget, + }, + actions: { + listActions: ({ cfg }) => { + if (!cfg.whatsapp) return []; + const gate = createActionGate(cfg.whatsapp.actions); + const actions = new Set(); + if (gate("reactions")) actions.add("react"); + if (gate("polls")) actions.add("poll"); + return Array.from(actions); + }, + supportsAction: ({ action }) => action === "react", + handleAction: async ({ action, params, cfg, accountId }) => { + if (action !== "react") { + throw new Error( + `Action ${action} is not supported for provider ${meta.id}.`, + ); + } + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = + typeof params.remove === "boolean" ? params.remove : undefined; + return await handleWhatsAppAction( + { + action: "react", + chatJid: + readStringParam(params, "chatJid") ?? + readStringParam(params, "to", { required: true }), + messageId, + emoji, + remove, + participant: readStringParam(params, "participant"), + accountId: accountId ?? undefined, + fromMe: + typeof params.fromMe === "boolean" ? params.fromMe : undefined, + }, + cfg, + ); + }, + }, + outbound: { + deliveryMode: "gateway", + chunker: chunkText, + textChunkLimit: 4000, + pollMaxOptions: 12, + resolveTarget: ({ to, allowFrom, mode }) => { + const trimmed = to?.trim() ?? ""; + const allowListRaw = (allowFrom ?? []) + .map((entry) => String(entry).trim()) + .filter(Boolean); + const hasWildcard = allowListRaw.includes("*"); + const allowList = allowListRaw + .filter((entry) => entry !== "*") + .map((entry) => normalizeWhatsAppTarget(entry)) + .filter((entry): entry is string => Boolean(entry)); + + if (trimmed) { + const normalizedTo = normalizeWhatsAppTarget(trimmed); + if (!normalizedTo) { + if ( + (mode === "implicit" || mode === "heartbeat") && + allowList.length > 0 + ) { + return { ok: true, to: allowList[0] }; + } + return { + ok: false, + error: new Error( + "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", + ), + }; + } + if (isWhatsAppGroupJid(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } + if (mode === "implicit" || mode === "heartbeat") { + if (hasWildcard || allowList.length === 0) { + return { ok: true, to: normalizedTo }; + } + if (allowList.includes(normalizedTo)) { + return { ok: true, to: normalizedTo }; + } + return { ok: true, to: allowList[0] }; + } + return { ok: true, to: normalizedTo }; + } + + if (allowList.length > 0) { + return { ok: true, to: allowList[0] }; + } + return { + ok: false, + error: new Error( + "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", + ), + }; + }, + sendText: async ({ to, text, accountId, deps, gifPlayback }) => { + const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const result = await send(to, text, { + verbose: false, + accountId: accountId ?? undefined, + gifPlayback, + }); + return { provider: "whatsapp", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => { + const send = deps?.sendWhatsApp ?? sendMessageWhatsApp; + const result = await send(to, text, { + verbose: false, + mediaUrl, + accountId: accountId ?? undefined, + gifPlayback, + }); + return { provider: "whatsapp", ...result }; + }, + sendPoll: async ({ to, poll, accountId }) => + await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId: accountId ?? undefined, + }), + }, + auth: { + login: async ({ cfg, accountId, runtime, verbose }) => { + const resolvedAccountId = + accountId?.trim() || resolveDefaultWhatsAppAccountId(cfg); + const { loginWeb } = await import("../../web/login.js"); + await loginWeb(Boolean(verbose), undefined, runtime, resolvedAccountId); + }, + }, + heartbeat: { + checkReady: async ({ cfg, accountId, deps }) => { + if (cfg.web?.enabled === false) { + return { ok: false, reason: "whatsapp-disabled" }; + } + const account = resolveWhatsAppAccount({ cfg, accountId }); + const authExists = await (deps?.webAuthExists ?? webAuthExists)( + account.authDir, + ); + if (!authExists) { + return { ok: false, reason: "whatsapp-not-linked" }; + } + const listenerActive = deps?.hasActiveWebListener + ? deps.hasActiveWebListener() + : Boolean(getActiveWebListener()); + if (!listenerActive) { + return { ok: false, reason: "whatsapp-not-running" }; + } + return { ok: true, reason: "ok" }; + }, + resolveRecipients: ({ cfg, opts }) => + resolveWhatsAppHeartbeatRecipients(cfg, opts), + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + connected: false, + reconnectAttempts: 0, + lastConnectedAt: null, + lastDisconnect: null, + lastMessageAt: null, + lastEventAt: null, + lastError: null, + }, + collectStatusIssues: collectWhatsAppStatusIssues, + buildProviderSummary: async ({ account, snapshot }) => { + const authDir = account.authDir; + const linked = + typeof snapshot.linked === "boolean" + ? snapshot.linked + : authDir + ? await webAuthExists(authDir) + : false; + const authAgeMs = linked && authDir ? getWebAuthAgeMs(authDir) : null; + const self = + linked && authDir ? readWebSelfId(authDir) : { e164: null, jid: null }; + return { + configured: linked, + linked, + authAgeMs, + self, + running: snapshot.running ?? false, + connected: snapshot.connected ?? false, + lastConnectedAt: snapshot.lastConnectedAt ?? null, + lastDisconnect: snapshot.lastDisconnect ?? null, + reconnectAttempts: snapshot.reconnectAttempts, + lastMessageAt: snapshot.lastMessageAt ?? null, + lastEventAt: snapshot.lastEventAt ?? null, + lastError: snapshot.lastError ?? null, + }; + }, + buildAccountSnapshot: async ({ account, runtime }) => { + const linked = await webAuthExists(account.authDir); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: true, + linked, + running: runtime?.running ?? false, + connected: runtime?.connected ?? false, + reconnectAttempts: runtime?.reconnectAttempts, + lastConnectedAt: runtime?.lastConnectedAt ?? null, + lastDisconnect: runtime?.lastDisconnect ?? null, + lastMessageAt: runtime?.lastMessageAt ?? null, + lastEventAt: runtime?.lastEventAt ?? null, + lastError: runtime?.lastError ?? null, + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }; + }, + resolveAccountState: ({ configured }) => + configured ? "linked" : "not linked", + logSelfId: ({ account, runtime, includeProviderPrefix }) => { + logWebSelfId(account.authDir, runtime, includeProviderPrefix); + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const { e164, jid } = readWebSelfId(account.authDir); + const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown"; + ctx.log?.info(`[${account.accountId}] starting provider (${identity})`); + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + const { monitorWebProvider } = await import("../web/index.js"); + return monitorWebProvider( + shouldLogVerbose(), + undefined, + true, + undefined, + ctx.runtime, + ctx.abortSignal, + { + statusSink: (next) => + ctx.setStatus({ accountId: ctx.accountId, ...next }), + accountId: account.accountId, + }, + ); + }, + loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => + await (async () => { + const { startWebLoginWithQr } = await import("../../web/login-qr.js"); + return await startWebLoginWithQr({ + accountId, + force, + timeoutMs, + verbose, + }); + })(), + loginWithQrWait: async ({ accountId, timeoutMs }) => + await (async () => { + const { waitForWebLogin } = await import("../../web/login-qr.js"); + return await waitForWebLogin({ accountId, timeoutMs }); + })(), + logoutAccount: async ({ account, runtime }) => { + const cleared = await logoutWeb({ + authDir: account.authDir, + isLegacyAuthDir: account.isLegacyAuthDir, + runtime, + }); + return { cleared, loggedOut: cleared }; + }, + }, +}; diff --git a/src/providers/registry.test.ts b/src/providers/registry.test.ts index 805232ad1..f20c2fdc1 100644 --- a/src/providers/registry.test.ts +++ b/src/providers/registry.test.ts @@ -10,6 +10,7 @@ describe("provider registry", () => { it("normalizes aliases", () => { expect(normalizeChatProviderId("imsg")).toBe("imessage"); expect(normalizeChatProviderId("teams")).toBe("msteams"); + expect(normalizeChatProviderId("web")).toBeNull(); }); it("keeps Telegram first in the default order", () => { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index d21f29269..a35eb02f3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,5 +1,5 @@ -import { normalizeMessageProvider } from "../utils/message-provider.js"; - +// Provider docking: add new providers here (order + meta + aliases), then +// register the plugin in src/providers/plugins/index.ts and keep protocol IDs in sync. export const CHAT_PROVIDER_ORDER = [ "telegram", "whatsapp", @@ -12,6 +12,10 @@ export const CHAT_PROVIDER_ORDER = [ export type ChatProviderId = (typeof CHAT_PROVIDER_ORDER)[number]; +export const PROVIDER_IDS = [...CHAT_PROVIDER_ORDER] as const; + +export const DEFAULT_CHAT_PROVIDER: ChatProviderId = "whatsapp"; + export type ChatProviderMeta = { id: ChatProviderId; label: string; @@ -19,8 +23,15 @@ export type ChatProviderMeta = { docsPath: string; docsLabel?: string; blurb: string; + // Provider docking: selection-line formatting for onboarding prompts. + // Keep this data-driven to avoid provider-specific branches in shared code. + selectionDocsPrefix?: string; + selectionDocsOmitLabel?: boolean; + selectionExtras?: string[]; }; +const WEBSITE_URL = "https://clawd.bot"; + const CHAT_PROVIDER_META: Record = { telegram: { id: "telegram", @@ -30,6 +41,9 @@ const CHAT_PROVIDER_META: Record = { docsLabel: "telegram", blurb: "simplest way to get started — register a bot with @BotFather and get going.", + selectionDocsPrefix: "", + selectionDocsOmitLabel: true, + selectionExtras: [WEBSITE_URL], }, whatsapp: { id: "whatsapp", @@ -82,12 +96,24 @@ const CHAT_PROVIDER_META: Record = { }, }; -const WEBSITE_URL = "https://clawd.bot"; +export const CHAT_PROVIDER_ALIASES: Record = { + imsg: "imessage", + teams: "msteams", +}; + +const normalizeProviderKey = (raw?: string | null): string | undefined => { + const normalized = raw?.trim().toLowerCase(); + return normalized || undefined; +}; export function listChatProviders(): ChatProviderMeta[] { return CHAT_PROVIDER_ORDER.map((id) => CHAT_PROVIDER_META[id]); } +export function listChatProviderAliases(): string[] { + return Object.keys(CHAT_PROVIDER_ALIASES); +} + export function getChatProviderMeta(id: ChatProviderId): ChatProviderMeta { return CHAT_PROVIDER_META[id]; } @@ -95,13 +121,22 @@ export function getChatProviderMeta(id: ChatProviderId): ChatProviderMeta { export function normalizeChatProviderId( raw?: string | null, ): ChatProviderId | null { - const normalized = normalizeMessageProvider(raw); + const normalized = normalizeProviderKey(raw); if (!normalized) return null; - return CHAT_PROVIDER_ORDER.includes(normalized as ChatProviderId) - ? (normalized as ChatProviderId) + const resolved = CHAT_PROVIDER_ALIASES[normalized] ?? normalized; + return CHAT_PROVIDER_ORDER.includes(resolved as ChatProviderId) + ? (resolved as ChatProviderId) : null; } +// Provider docking: prefer this helper in shared code. Importing from +// `src/providers/plugins/*` can eagerly load provider implementations. +export function normalizeProviderId( + raw?: string | null, +): ChatProviderId | null { + return normalizeChatProviderId(raw); +} + export function formatProviderPrimerLine(meta: ChatProviderMeta): string { return `${meta.label}: ${meta.blurb}`; } @@ -110,13 +145,11 @@ export function formatProviderSelectionLine( meta: ChatProviderMeta, docsLink: (path: string, label?: string) => string, ): string { - if (meta.id === "telegram") { - return `${meta.label} — ${meta.blurb} ${docsLink( - meta.docsPath, - )} ${WEBSITE_URL}`; - } - return `${meta.label} — ${meta.blurb} Docs: ${docsLink( - meta.docsPath, - meta.docsLabel ?? meta.id, - )}`; + const docsPrefix = meta.selectionDocsPrefix ?? "Docs:"; + const docsLabel = meta.docsLabel ?? meta.id; + const docs = meta.selectionDocsOmitLabel + ? docsLink(meta.docsPath) + : docsLink(meta.docsPath, docsLabel); + const extras = (meta.selectionExtras ?? []).filter(Boolean).join(" "); + return `${meta.label} — ${meta.blurb} ${docsPrefix ? `${docsPrefix} ` : ""}${docs}${extras ? ` ${extras}` : ""}`; } diff --git a/src/telegram/bot.media.test.ts b/src/telegram/bot.media.test.ts index 3bcef4c54..8fc16295f 100644 --- a/src/telegram/bot.media.test.ts +++ b/src/telegram/bot.media.test.ts @@ -42,6 +42,19 @@ vi.mock("@grammyjs/transformer-throttler", () => ({ apiThrottler: () => throttlerSpy(), })); +vi.mock("../media/store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveMediaBuffer: vi.fn(async (buffer: Buffer, contentType?: string) => ({ + id: "media", + path: "/tmp/telegram-media", + size: buffer.byteLength, + contentType: contentType ?? "application/octet-stream", + })), + }; +}); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index f56aa8e77..8e7f62f6a 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -19,7 +19,6 @@ import { listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; -import { resolveTelegramDraftStreamingChunking } from "../auto-reply/reply/block-streaming.js"; import { buildHistoryContextFromMap, clearHistoryEntries, @@ -62,6 +61,7 @@ import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; import { resolveTelegramAccount } from "./accounts.js"; +import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; import { createTelegramDraftStream } from "./draft-stream.js"; import { resolveTelegramFetch } from "./fetch.js"; import { markdownToTelegramHtml } from "./format.js"; diff --git a/src/auto-reply/reply/block-streaming.test.ts b/src/telegram/draft-chunking.test.ts similarity index 94% rename from src/auto-reply/reply/block-streaming.test.ts rename to src/telegram/draft-chunking.test.ts index 17b6c505e..5f2c77af1 100644 --- a/src/auto-reply/reply/block-streaming.test.ts +++ b/src/telegram/draft-chunking.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../../config/config.js"; -import { resolveTelegramDraftStreamingChunking } from "./block-streaming.js"; +import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; describe("resolveTelegramDraftStreamingChunking", () => { it("uses smaller defaults than block streaming", () => { diff --git a/src/telegram/draft-chunking.ts b/src/telegram/draft-chunking.ts new file mode 100644 index 000000000..67e7510af --- /dev/null +++ b/src/telegram/draft-chunking.ts @@ -0,0 +1,43 @@ +import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { getProviderDock } from "../providers/dock.js"; +import { normalizeAccountId } from "../routing/session-key.js"; + +const DEFAULT_TELEGRAM_DRAFT_STREAM_MIN = 200; +const DEFAULT_TELEGRAM_DRAFT_STREAM_MAX = 800; + +export function resolveTelegramDraftStreamingChunking( + cfg: ClawdbotConfig | undefined, + accountId?: string | null, +): { + minChars: number; + maxChars: number; + breakPreference: "paragraph" | "newline" | "sentence"; +} { + const providerChunkLimit = + getProviderDock("telegram")?.outbound?.textChunkLimit; + const textLimit = resolveTextChunkLimit(cfg, "telegram", accountId, { + fallbackLimit: providerChunkLimit, + }); + const normalizedAccountId = normalizeAccountId(accountId); + const draftCfg = + cfg?.telegram?.accounts?.[normalizedAccountId]?.draftChunk ?? + cfg?.telegram?.draftChunk; + + const maxRequested = Math.max( + 1, + Math.floor(draftCfg?.maxChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MAX), + ); + const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); + const minRequested = Math.max( + 1, + Math.floor(draftCfg?.minChars ?? DEFAULT_TELEGRAM_DRAFT_STREAM_MIN), + ); + const minChars = Math.min(minRequested, maxChars); + const breakPreference = + draftCfg?.breakPreference === "newline" || + draftCfg?.breakPreference === "sentence" + ? draftCfg.breakPreference + : "paragraph"; + return { minChars, maxChars, breakPreference }; +} diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 5b52568bd..8dad78c15 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -7,6 +7,10 @@ import { type SessionsListParams, type SessionsPatchParams, } from "../gateway/protocol/index.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-provider.js"; import { VERSION } from "../version.js"; export type GatewayConnectionOptions = { @@ -104,10 +108,11 @@ export class GatewayChatClient { url: resolved.url, token: resolved.token, password: resolved.password, - clientName: "clawdbot-tui", + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: "clawdbot-tui", clientVersion: VERSION, platform: process.platform, - mode: "tui", + mode: GATEWAY_CLIENT_MODES.UI, instanceId: randomUUID(), minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, diff --git a/src/tui/tui.ts b/src/tui/tui.ts index b0d70e355..ca8e3c558 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -76,7 +76,11 @@ type AgentSummary = { }; type GatewayStatusSummary = { - web?: { linked?: boolean; authAgeMs?: number | null }; + linkProvider?: { + label?: string; + linked?: boolean; + authAgeMs?: number | null; + }; heartbeatSeconds?: number; providerSummary?: string[]; queuedSystemEvents?: string[]; @@ -328,12 +332,17 @@ export async function runTui(opts: TuiOptions) { const lines: string[] = []; lines.push("Gateway status"); - const webLinked = summary.web?.linked === true; - const authAge = - webLinked && typeof summary.web?.authAgeMs === "number" - ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` - : ""; - lines.push(`Web session: ${webLinked ? "linked" : "not linked"}${authAge}`); + if (!summary.linkProvider) { + lines.push("Link provider: unknown"); + } else { + const linkLabel = summary.linkProvider.label ?? "Link provider"; + const linked = summary.linkProvider.linked === true; + const authAge = + linked && typeof summary.linkProvider.authAgeMs === "number" + ? ` (last refreshed ${formatAge(summary.linkProvider.authAgeMs)})` + : ""; + lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`); + } const providerSummary = Array.isArray(summary.providerSummary) ? summary.providerSummary diff --git a/src/utils/message-provider.test.ts b/src/utils/message-provider.test.ts index ecccf7613..ef2ec5dc0 100644 --- a/src/utils/message-provider.test.ts +++ b/src/utils/message-provider.test.ts @@ -7,6 +7,7 @@ describe("message-provider", () => { expect(resolveGatewayMessageProvider("discord")).toBe("discord"); expect(resolveGatewayMessageProvider(" imsg ")).toBe("imessage"); expect(resolveGatewayMessageProvider("teams")).toBe("msteams"); + expect(resolveGatewayMessageProvider("web")).toBeUndefined(); expect(resolveGatewayMessageProvider("nope")).toBeUndefined(); }); }); diff --git a/src/utils/message-provider.ts b/src/utils/message-provider.ts index 6da32f764..bcf049811 100644 --- a/src/utils/message-provider.ts +++ b/src/utils/message-provider.ts @@ -1,52 +1,86 @@ +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + type GatewayClientMode, + type GatewayClientName, + normalizeGatewayClientMode, + normalizeGatewayClientName, +} from "../gateway/protocol/client-info.js"; +import { + listChatProviderAliases, + normalizeChatProviderId, + PROVIDER_IDS, +} from "../providers/registry.js"; + +export const INTERNAL_MESSAGE_PROVIDER = "webchat" as const; +export type InternalMessageProvider = typeof INTERNAL_MESSAGE_PROVIDER; + +export { GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_MODES }; +export type { GatewayClientName, GatewayClientMode }; +export { normalizeGatewayClientName, normalizeGatewayClientMode }; + +type GatewayClientInfoLike = { + mode?: string | null; + id?: string | null; +}; + +export function isGatewayCliClient( + client?: GatewayClientInfoLike | null, +): boolean { + return normalizeGatewayClientMode(client?.mode) === GATEWAY_CLIENT_MODES.CLI; +} + +export function isInternalMessageProvider( + raw?: string | null, +): raw is InternalMessageProvider { + return normalizeMessageProvider(raw) === INTERNAL_MESSAGE_PROVIDER; +} + +export function isWebchatClient( + client?: GatewayClientInfoLike | null, +): boolean { + const mode = normalizeGatewayClientMode(client?.mode); + if (mode === GATEWAY_CLIENT_MODES.WEBCHAT) return true; + return ( + normalizeGatewayClientName(client?.id) === GATEWAY_CLIENT_NAMES.WEBCHAT_UI + ); +} + export function normalizeMessageProvider( raw?: string | null, ): string | undefined { const normalized = raw?.trim().toLowerCase(); if (!normalized) return undefined; - if (normalized === "imsg") return "imessage"; - if (normalized === "teams") return "msteams"; - return normalized; + if (normalized === INTERNAL_MESSAGE_PROVIDER) + return INTERNAL_MESSAGE_PROVIDER; + return normalizeChatProviderId(normalized) ?? normalized; } -export const DELIVERABLE_MESSAGE_PROVIDERS = [ - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "msteams", -] as const; +export const DELIVERABLE_MESSAGE_PROVIDERS = PROVIDER_IDS; export type DeliverableMessageProvider = (typeof DELIVERABLE_MESSAGE_PROVIDERS)[number]; -export const INTERNAL_MESSAGE_PROVIDER = "webchat" as const; -export type InternalMessageProvider = typeof INTERNAL_MESSAGE_PROVIDER; - export type GatewayMessageProvider = | DeliverableMessageProvider | InternalMessageProvider; export const GATEWAY_MESSAGE_PROVIDERS = [ ...DELIVERABLE_MESSAGE_PROVIDERS, - "webchat", + INTERNAL_MESSAGE_PROVIDER, ] as const; -export const GATEWAY_AGENT_PROVIDER_ALIASES = ["imsg", "teams"] as const; -export type GatewayAgentProviderAlias = - (typeof GATEWAY_AGENT_PROVIDER_ALIASES)[number]; +export const GATEWAY_AGENT_PROVIDER_ALIASES = listChatProviderAliases(); -export type GatewayAgentProviderHint = - | GatewayMessageProvider - | "last" - | GatewayAgentProviderAlias; +export type GatewayAgentProviderHint = GatewayMessageProvider | "last"; -export const GATEWAY_AGENT_PROVIDER_VALUES = [ - ...GATEWAY_MESSAGE_PROVIDERS, - "last", - ...GATEWAY_AGENT_PROVIDER_ALIASES, -] as const; +export const GATEWAY_AGENT_PROVIDER_VALUES = Array.from( + new Set([ + ...GATEWAY_MESSAGE_PROVIDERS, + "last", + ...GATEWAY_AGENT_PROVIDER_ALIASES, + ]), +); export function isGatewayMessageProvider( value: string, diff --git a/src/web/auth-store.ts b/src/web/auth-store.ts new file mode 100644 index 000000000..027089c9c --- /dev/null +++ b/src/web/auth-store.ts @@ -0,0 +1,188 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { resolveOAuthDir } from "../config/paths.js"; +import { info, success } from "../globals.js"; +import { getChildLogger } from "../logging.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import type { Provider } from "../utils.js"; +import { jidToE164, resolveUserPath } from "../utils.js"; + +export function resolveDefaultWebAuthDir(): string { + return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID); +} + +export const WA_WEB_AUTH_DIR = resolveDefaultWebAuthDir(); + +export function resolveWebCredsPath(authDir: string): string { + return path.join(authDir, "creds.json"); +} + +export function resolveWebCredsBackupPath(authDir: string): string { + return path.join(authDir, "creds.json.bak"); +} + +function readCredsJsonRaw(filePath: string): string | null { + try { + if (!fsSync.existsSync(filePath)) return null; + const stats = fsSync.statSync(filePath); + if (!stats.isFile() || stats.size <= 1) return null; + return fsSync.readFileSync(filePath, "utf-8"); + } catch { + return null; + } +} + +export function maybeRestoreCredsFromBackup(authDir: string): void { + const logger = getChildLogger({ module: "web-session" }); + try { + const credsPath = resolveWebCredsPath(authDir); + const backupPath = resolveWebCredsBackupPath(authDir); + const raw = readCredsJsonRaw(credsPath); + if (raw) { + // Validate that creds.json is parseable. + JSON.parse(raw); + return; + } + + const backupRaw = readCredsJsonRaw(backupPath); + if (!backupRaw) return; + + // Ensure backup is parseable before restoring. + JSON.parse(backupRaw); + fsSync.copyFileSync(backupPath, credsPath); + logger.warn( + { credsPath }, + "restored corrupted WhatsApp creds.json from backup", + ); + } catch { + // ignore + } +} + +export async function webAuthExists( + authDir: string = resolveDefaultWebAuthDir(), +) { + const resolvedAuthDir = resolveUserPath(authDir); + maybeRestoreCredsFromBackup(resolvedAuthDir); + const credsPath = resolveWebCredsPath(resolvedAuthDir); + try { + await fs.access(resolvedAuthDir); + } catch { + return false; + } + try { + const stats = await fs.stat(credsPath); + if (!stats.isFile() || stats.size <= 1) return false; + const raw = await fs.readFile(credsPath, "utf-8"); + JSON.parse(raw); + return true; + } catch { + return false; + } +} + +async function clearLegacyBaileysAuthState(authDir: string) { + const entries = await fs.readdir(authDir, { withFileTypes: true }); + const shouldDelete = (name: string) => { + if (name === "oauth.json") return false; + if (name === "creds.json" || name === "creds.json.bak") return true; + if (!name.endsWith(".json")) return false; + return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); + }; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isFile()) return; + if (!shouldDelete(entry.name)) return; + await fs.rm(path.join(authDir, entry.name), { force: true }); + }), + ); +} + +export async function logoutWeb(params: { + authDir?: string; + isLegacyAuthDir?: boolean; + runtime?: RuntimeEnv; +}) { + const runtime = params.runtime ?? defaultRuntime; + const resolvedAuthDir = resolveUserPath( + params.authDir ?? resolveDefaultWebAuthDir(), + ); + const exists = await webAuthExists(resolvedAuthDir); + if (!exists) { + runtime.log(info("No WhatsApp Web session found; nothing to delete.")); + return false; + } + if (params.isLegacyAuthDir) { + await clearLegacyBaileysAuthState(resolvedAuthDir); + } else { + await fs.rm(resolvedAuthDir, { recursive: true, force: true }); + } + runtime.log(success("Cleared WhatsApp Web credentials.")); + return true; +} + +export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) { + // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. + try { + const credsPath = resolveWebCredsPath(resolveUserPath(authDir)); + if (!fsSync.existsSync(credsPath)) { + return { e164: null, jid: null } as const; + } + const raw = fsSync.readFileSync(credsPath, "utf-8"); + const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined; + const jid = parsed?.me?.id ?? null; + const e164 = jid ? jidToE164(jid, { authDir }) : null; + return { e164, jid } as const; + } catch { + return { e164: null, jid: null } as const; + } +} + +/** + * Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing. + * Helpful for heartbeats/observability to spot stale credentials. + */ +export function getWebAuthAgeMs( + authDir: string = resolveDefaultWebAuthDir(), +): number | null { + try { + const stats = fsSync.statSync( + resolveWebCredsPath(resolveUserPath(authDir)), + ); + return Date.now() - stats.mtimeMs; + } catch { + return null; + } +} + +export function logWebSelfId( + authDir: string = resolveDefaultWebAuthDir(), + runtime: RuntimeEnv = defaultRuntime, + includeProviderPrefix = false, +) { + // Human-friendly log of the currently linked personal web session. + const { e164, jid } = readWebSelfId(authDir); + const details = + e164 || jid + ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` + : "unknown"; + const prefix = includeProviderPrefix ? "Web Provider: " : ""; + runtime.log(info(`${prefix}${details}`)); +} + +export async function pickProvider( + pref: Provider | "auto", + authDir: string = resolveDefaultWebAuthDir(), +): Promise { + const choice: Provider = pref === "auto" ? "web" : pref; + const hasWeb = await webAuthExists(authDir); + if (!hasWeb) { + throw new Error( + "No WhatsApp Web session found. Run `clawdbot providers login --verbose` to link.", + ); + } + return choice; +} diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 6b3c4adc0..21dc06286 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -51,6 +51,7 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; import { toLocationContext } from "../providers/location.js"; +import { resolveWhatsAppHeartbeatRecipients } from "../providers/plugins/whatsapp-heartbeat.js"; import { buildAgentSessionKey, resolveAgentRoute, @@ -495,71 +496,11 @@ export async function runWebHeartbeatOnce(opts: { } } -function getSessionRecipients(cfg: ReturnType) { - const sessionCfg = cfg.session; - const scope = sessionCfg?.scope ?? "per-sender"; - if (scope === "global") return []; - const storePath = resolveStorePath(cfg.session?.store); - const store = loadSessionStore(storePath); - const isGroupKey = (key: string) => - key.startsWith("group:") || - key.includes(":group:") || - key.includes(":channel:") || - key.includes("@g.us"); - const isCronKey = (key: string) => key.startsWith("cron:"); - - const recipients = Object.entries(store) - .filter(([key]) => key !== "global" && key !== "unknown") - .filter(([key]) => !isGroupKey(key) && !isCronKey(key)) - .map(([_, entry]) => ({ - to: - entry?.lastProvider === "whatsapp" && entry?.lastTo - ? normalizeE164(entry.lastTo) - : "", - updatedAt: entry?.updatedAt ?? 0, - })) - .filter(({ to }) => to.length > 1) - .sort((a, b) => b.updatedAt - a.updatedAt); - - // Dedupe while preserving recency ordering. - const seen = new Set(); - return recipients.filter((r) => { - if (seen.has(r.to)) return false; - seen.add(r.to); - return true; - }); -} - export function resolveHeartbeatRecipients( cfg: ReturnType, opts: { to?: string; all?: boolean } = {}, ) { - if (opts.to) return { recipients: [normalizeE164(opts.to)], source: "flag" }; - - const sessionRecipients = getSessionRecipients(cfg); - const allowFrom = - Array.isArray(cfg.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0 - ? cfg.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164) - : []; - - const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; - - if (opts.all) { - const all = unique([...sessionRecipients.map((s) => s.to), ...allowFrom]); - return { recipients: all, source: "all" as const }; - } - - if (sessionRecipients.length === 1) { - return { recipients: [sessionRecipients[0].to], source: "session-single" }; - } - if (sessionRecipients.length > 1) { - return { - recipients: sessionRecipients.map((s) => s.to), - source: "session-ambiguous" as const, - }; - } - - return { recipients: allowFrom, source: "allowFrom" as const }; + return resolveWhatsAppHeartbeatRecipients(cfg, opts); } function getSessionSnapshot( diff --git a/src/web/login.coverage.test.ts b/src/web/login.coverage.test.ts index a74413a01..5cfd9ffb3 100644 --- a/src/web/login.coverage.test.ts +++ b/src/web/login.coverage.test.ts @@ -61,7 +61,7 @@ describe("loginWeb coverage", () => { .mockResolvedValueOnce(undefined); const runtime = { log: vi.fn(), error: vi.fn() } as never; - await loginWeb(false, "web", waitForWaConnection as never, runtime); + await loginWeb(false, waitForWaConnection as never, runtime); expect(createWaSocket).toHaveBeenCalledTimes(2); const firstSock = await createWaSocket.mock.results[0].value; @@ -76,9 +76,9 @@ describe("loginWeb coverage", () => { output: { statusCode: DisconnectReason.loggedOut }, }); - await expect( - loginWeb(false, "web", waitForWaConnection as never), - ).rejects.toThrow(/cache cleared/i); + await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow( + /cache cleared/i, + ); expect(rmMock).toHaveBeenCalledWith(authDir, { recursive: true, force: true, @@ -87,9 +87,9 @@ describe("loginWeb coverage", () => { it("formats and rethrows generic errors", async () => { waitForWaConnection.mockRejectedValueOnce(new Error("boom")); - await expect( - loginWeb(false, "web", waitForWaConnection as never), - ).rejects.toThrow("formatted:Error: boom"); + await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow( + "formatted:Error: boom", + ); expect(formatError).toHaveBeenCalled(); }); }); diff --git a/src/web/login.test.ts b/src/web/login.test.ts index 633f2ff84..087f406bd 100644 --- a/src/web/login.test.ts +++ b/src/web/login.test.ts @@ -39,7 +39,7 @@ describe("web login", () => { const waiter: typeof waitForWaConnection = vi .fn() .mockResolvedValue(undefined); - await loginWeb(false, "web", waiter); + await loginWeb(false, waiter); await new Promise((resolve) => setTimeout(resolve, 550)); expect(close).toHaveBeenCalled(); }); diff --git a/src/web/login.ts b/src/web/login.ts index fd48ade4d..793e274be 100644 --- a/src/web/login.ts +++ b/src/web/login.ts @@ -13,14 +13,10 @@ import { export async function loginWeb( verbose: boolean, - provider = "whatsapp", waitForConnection?: typeof waitForWaConnection, runtime: RuntimeEnv = defaultRuntime, accountId?: string, ) { - if (provider !== "whatsapp" && provider !== "web") { - throw new Error(`Unsupported provider: ${provider}`); - } const wait = waitForConnection ?? waitForWaConnection; const cfg = loadConfig(); const account = resolveWhatsAppAccount({ cfg, accountId }); @@ -52,11 +48,7 @@ export async function loginWeb( }); try { await wait(retry); - console.log( - success( - "✅ Linked after restart; web session ready. You can now send with provider=web.", - ), - ); + console.log(success("✅ Linked after restart; web session ready.")); return; } finally { setTimeout(() => retry.ws?.close(), 500); diff --git a/src/web/session.ts b/src/web/session.ts index 90e67a1bd..8dbc308a0 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -1,7 +1,5 @@ import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; -import fs from "node:fs/promises"; -import path from "node:path"; import { DisconnectReason, fetchLatestBaileysVersion, @@ -10,28 +8,27 @@ import { useMultiFileAuthState, } from "@whiskeysockets/baileys"; import qrcode from "qrcode-terminal"; -import { resolveOAuthDir } from "../config/paths.js"; -import { danger, info, success } from "../globals.js"; +import { danger, success } from "../globals.js"; import { getChildLogger, toPinoLikeLogger } from "../logging.js"; -import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import type { Provider } from "../utils.js"; -import { ensureDir, jidToE164, resolveUserPath } from "../utils.js"; +import { ensureDir, resolveUserPath } from "../utils.js"; import { VERSION } from "../version.js"; -function resolveDefaultWebAuthDir(): string { - return path.join(resolveOAuthDir(), "whatsapp", DEFAULT_ACCOUNT_ID); -} +import { + maybeRestoreCredsFromBackup, + resolveDefaultWebAuthDir, + resolveWebCredsBackupPath, + resolveWebCredsPath, +} from "./auth-store.js"; -export const WA_WEB_AUTH_DIR = resolveDefaultWebAuthDir(); - -function resolveWebCredsPath(authDir: string) { - return path.join(authDir, "creds.json"); -} - -function resolveWebCredsBackupPath(authDir: string) { - return path.join(authDir, "creds.json.bak"); -} +export { + getWebAuthAgeMs, + logoutWeb, + logWebSelfId, + pickProvider, + readWebSelfId, + WA_WEB_AUTH_DIR, + webAuthExists, +} from "./auth-store.js"; let credsSaveQueue: Promise = Promise.resolve(); function enqueueSaveCreds( @@ -57,35 +54,6 @@ function readCredsJsonRaw(filePath: string): string | null { } } -function maybeRestoreCredsFromBackup( - authDir: string, - logger: ReturnType, -): void { - try { - const credsPath = resolveWebCredsPath(authDir); - const backupPath = resolveWebCredsBackupPath(authDir); - const raw = readCredsJsonRaw(credsPath); - if (raw) { - // Validate that creds.json is parseable. - JSON.parse(raw); - return; - } - - const backupRaw = readCredsJsonRaw(backupPath); - if (!backupRaw) return; - - // Ensure backup is parseable before restoring. - JSON.parse(backupRaw); - fsSync.copyFileSync(backupPath, credsPath); - logger.warn( - { credsPath }, - "restored corrupted WhatsApp creds.json from backup", - ); - } catch { - // ignore - } -} - async function safeSaveCreds( authDir: string, saveCreds: () => Promise | void, @@ -134,7 +102,7 @@ export async function createWaSocket( const authDir = resolveUserPath(opts.authDir ?? resolveDefaultWebAuthDir()); await ensureDir(authDir); const sessionLogger = getChildLogger({ module: "web-session" }); - maybeRestoreCredsFromBackup(authDir, sessionLogger); + maybeRestoreCredsFromBackup(authDir); const { state, saveCreds } = await useMultiFileAuthState(authDir); const { version } = await fetchLatestBaileysVersion(); const sock = makeWASocket({ @@ -332,132 +300,6 @@ export function formatError(err: unknown): string { return safeStringify(err); } -export async function webAuthExists( - authDir: string = resolveDefaultWebAuthDir(), -) { - const sessionLogger = getChildLogger({ module: "web-session" }); - const resolvedAuthDir = resolveUserPath(authDir); - maybeRestoreCredsFromBackup(resolvedAuthDir, sessionLogger); - const credsPath = resolveWebCredsPath(resolvedAuthDir); - try { - await fs.access(resolvedAuthDir); - } catch { - return false; - } - try { - const stats = await fs.stat(credsPath); - if (!stats.isFile() || stats.size <= 1) return false; - const raw = await fs.readFile(credsPath, "utf-8"); - JSON.parse(raw); - return true; - } catch { - return false; - } -} - -async function clearLegacyBaileysAuthState(authDir: string) { - const entries = await fs.readdir(authDir, { withFileTypes: true }); - const shouldDelete = (name: string) => { - if (name === "oauth.json") return false; - if (name === "creds.json" || name === "creds.json.bak") return true; - if (!name.endsWith(".json")) return false; - return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); - }; - await Promise.all( - entries.map(async (entry) => { - if (!entry.isFile()) return; - if (!shouldDelete(entry.name)) return; - await fs.rm(path.join(authDir, entry.name), { force: true }); - }), - ); -} - -export async function logoutWeb(params: { - authDir?: string; - isLegacyAuthDir?: boolean; - runtime?: RuntimeEnv; -}) { - const runtime = params.runtime ?? defaultRuntime; - const resolvedAuthDir = resolveUserPath( - params.authDir ?? resolveDefaultWebAuthDir(), - ); - const exists = await webAuthExists(resolvedAuthDir); - if (!exists) { - runtime.log(info("No WhatsApp Web session found; nothing to delete.")); - return false; - } - if (params.isLegacyAuthDir) { - await clearLegacyBaileysAuthState(resolvedAuthDir); - } else { - await fs.rm(resolvedAuthDir, { recursive: true, force: true }); - } - runtime.log(success("Cleared WhatsApp Web credentials.")); - return true; -} - -export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) { - // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. - try { - const credsPath = resolveWebCredsPath(resolveUserPath(authDir)); - if (!fsSync.existsSync(credsPath)) { - return { e164: null, jid: null } as const; - } - const raw = fsSync.readFileSync(credsPath, "utf-8"); - const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined; - const jid = parsed?.me?.id ?? null; - const e164 = jid ? jidToE164(jid, { authDir }) : null; - return { e164, jid } as const; - } catch { - return { e164: null, jid: null } as const; - } -} - -/** - * Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing. - * Helpful for heartbeats/observability to spot stale credentials. - */ -export function getWebAuthAgeMs( - authDir: string = resolveDefaultWebAuthDir(), -): number | null { - try { - const stats = fsSync.statSync( - resolveWebCredsPath(resolveUserPath(authDir)), - ); - return Date.now() - stats.mtimeMs; - } catch { - return null; - } -} - export function newConnectionId() { return randomUUID(); } - -export function logWebSelfId( - authDir: string = resolveDefaultWebAuthDir(), - runtime: RuntimeEnv = defaultRuntime, - includeProviderPrefix = false, -) { - // Human-friendly log of the currently linked personal web session. - const { e164, jid } = readWebSelfId(authDir); - const details = - e164 || jid - ? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}` - : "unknown"; - const prefix = includeProviderPrefix ? "Web Provider: " : ""; - runtime.log(info(`${prefix}${details}`)); -} - -export async function pickProvider( - pref: Provider | "auto", - authDir: string = resolveDefaultWebAuthDir(), -): Promise { - const choice: Provider = pref === "auto" ? "web" : pref; - const hasWeb = await webAuthExists(authDir); - if (!hasWeb) { - throw new Error( - "No WhatsApp Web session found. Run `clawdbot providers login --verbose` to link.", - ); - } - return choice; -} diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 6404bc9bd..d1b811599 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -59,6 +59,7 @@ import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; +import { listProviderPlugins } from "../providers/plugins/index.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { runTui } from "../tui/tui.js"; @@ -541,10 +542,15 @@ export async function runOnboardingWizard( if (opts.skipProviders) { await prompter.note("Skipping provider setup.", "Providers"); } else { + const quickstartAllowFromProviders = + flow === "quickstart" + ? listProviderPlugins() + .filter((plugin) => plugin.meta.quickstartAllowFrom) + .map((plugin) => plugin.id) + : []; nextConfig = await setupProviders(nextConfig, runtime, prompter, { allowSignalInstall: true, - forceAllowFromProviders: - flow === "quickstart" ? ["telegram", "whatsapp"] : [], + forceAllowFromProviders: quickstartAllowFromProviders, skipDmPolicyPrompt: flow === "quickstart", skipConfirm: flow === "quickstart", quickstartDefaults: flow === "quickstart", diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index 4b2eb3169..01f3ace38 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -60,10 +60,16 @@ export async function loadProviders(state: ConnectionsState, probe: boolean) { })) as ProvidersStatusSnapshot; state.providersSnapshot = res; state.providersLastSuccess = Date.now(); - state.telegramTokenLocked = res.telegram.tokenSource === "env"; - state.discordTokenLocked = res.discord?.tokenSource === "env"; - state.slackTokenLocked = res.slack?.botTokenSource === "env"; - state.slackAppTokenLocked = res.slack?.appTokenSource === "env"; + const providers = res.providers as Record; + const telegram = providers.telegram as { tokenSource?: string | null }; + const discord = providers.discord as { tokenSource?: string | null } | null; + const slack = providers.slack as + | { botTokenSource?: string | null; appTokenSource?: string | null } + | null; + state.telegramTokenLocked = telegram?.tokenSource === "env"; + state.discordTokenLocked = discord?.tokenSource === "env"; + state.slackTokenLocked = slack?.botTokenSource === "env"; + state.slackAppTokenLocked = slack?.appTokenSource === "env"; } catch (err) { state.providersError = String(err); } finally { @@ -113,7 +119,7 @@ export async function logoutWhatsApp(state: ConnectionsState) { if (!state.client || !state.connected || state.whatsappBusy) return; state.whatsappBusy = true; try { - await state.client.request("web.logout", {}); + await state.client.request("providers.logout", { provider: "whatsapp" }); state.whatsappLoginMessage = "Logged out."; state.whatsappLoginQrDataUrl = null; state.whatsappLoginConnected = null; diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 22b930a7e..31d4bb6b5 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -1,4 +1,10 @@ import { generateUUID } from "./uuid"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + type GatewayClientMode, + type GatewayClientName, +} from "../../../src/gateway/protocol/client-info.js"; export type GatewayEventFrame = { type: "event"; @@ -33,10 +39,10 @@ export type GatewayBrowserClientOptions = { url: string; token?: string; password?: string; - clientName?: string; + clientName?: GatewayClientName; clientVersion?: string; platform?: string; - mode?: string; + mode?: GatewayClientMode; instanceId?: string; onHello?: (hello: GatewayHelloOk) => void; onEvent?: (evt: GatewayEventFrame) => void; @@ -107,13 +113,13 @@ export class GatewayBrowserClient { } : undefined; const params = { - minProtocol: 2, - maxProtocol: 2, + minProtocol: 3, + maxProtocol: 3, client: { - name: this.opts.clientName ?? "clawdbot-control-ui", + id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, version: this.opts.clientVersion ?? "dev", platform: this.opts.platform ?? navigator.platform ?? "web", - mode: this.opts.mode ?? "webchat", + mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT, instanceId: this.opts.instanceId, }, caps: [], diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index c65fc92c1..b2d0263eb 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -1,11 +1,42 @@ export type ProvidersStatusSnapshot = { ts: number; - whatsapp: WhatsAppStatus; - telegram: TelegramStatus; - discord?: DiscordStatus | null; - slack?: SlackStatus | null; - signal?: SignalStatus | null; - imessage?: IMessageStatus | null; + providerOrder: string[]; + providerLabels: Record; + providers: Record; + providerAccounts: Record; + providerDefaultAccountId: Record; +}; + +export type ProviderAccountSnapshot = { + accountId: string; + name?: string | null; + enabled?: boolean | null; + configured?: boolean | null; + linked?: boolean | null; + running?: boolean | null; + connected?: boolean | null; + reconnectAttempts?: number | null; + lastConnectedAt?: number | null; + lastError?: string | null; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastInboundAt?: number | null; + lastOutboundAt?: number | null; + lastProbeAt?: number | null; + mode?: string | null; + dmPolicy?: string | null; + allowFrom?: string[] | null; + tokenSource?: string | null; + botTokenSource?: string | null; + appTokenSource?: string | null; + baseUrl?: string | null; + allowUnmentionedGroups?: boolean | null; + cliPath?: string | null; + dbPath?: string | null; + port?: number | null; + probe?: unknown; + audit?: unknown; + application?: unknown; }; export type WhatsAppSelf = { @@ -157,6 +188,23 @@ export type IMessageStatus = { lastProbeAt?: number | null; }; +export type MSTeamsProbe = { + ok: boolean; + error?: string | null; + appId?: string | null; +}; + +export type MSTeamsStatus = { + configured: boolean; + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + port?: number | null; + probe?: MSTeamsProbe | null; + lastProbeAt?: number | null; +}; + export type ConfigSnapshotIssue = { path: string; message: string; @@ -281,7 +329,8 @@ export type CronPayload = | "discord" | "slack" | "signal" - | "imessage"; + | "imessage" + | "msteams"; to?: string; bestEffortDeliver?: boolean; }; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 1c6e181ad..6e90728cf 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -176,7 +176,8 @@ export type CronFormState = { | "discord" | "slack" | "signal" - | "imessage"; + | "imessage" + | "msteams"; to: string; timeoutSeconds: string; postToMainPrefix: string; diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index fdc42ccf1..3201ab93c 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -1,7 +1,15 @@ import { html, nothing } from "lit"; import { formatAgo } from "../format"; -import type { ProvidersStatusSnapshot } from "../types"; +import type { + DiscordStatus, + IMessageStatus, + ProvidersStatusSnapshot, + SignalStatus, + SlackStatus, + TelegramStatus, + WhatsAppStatus, +} from "../types"; import type { DiscordActionForm, DiscordForm, @@ -84,12 +92,17 @@ export type ConnectionsProps = { }; export function renderConnections(props: ConnectionsProps) { - const whatsapp = props.snapshot?.whatsapp; - const telegram = props.snapshot?.telegram; - const discord = props.snapshot?.discord ?? null; - const slack = props.snapshot?.slack ?? null; - const signal = props.snapshot?.signal ?? null; - const imessage = props.snapshot?.imessage ?? null; + const providers = props.snapshot?.providers as Record | null; + const whatsapp = (providers?.whatsapp ?? undefined) as + | WhatsAppStatus + | undefined; + const telegram = (providers?.telegram ?? undefined) as + | TelegramStatus + | undefined; + const discord = (providers?.discord ?? null) as DiscordStatus | null; + const slack = (providers?.slack ?? null) as SlackStatus | null; + const signal = (providers?.signal ?? null) as SignalStatus | null; + const imessage = (providers?.imessage ?? null) as IMessageStatus | null; const providerOrder: ProviderKey[] = [ "whatsapp", "telegram", @@ -163,24 +176,31 @@ type ProviderKey = function providerEnabled(key: ProviderKey, props: ConnectionsProps) { const snapshot = props.snapshot; - if (!snapshot) return false; + const providers = snapshot?.providers as Record | null; + if (!snapshot || !providers) return false; + const whatsapp = providers.whatsapp as WhatsAppStatus | undefined; + const telegram = providers.telegram as TelegramStatus | undefined; + const discord = (providers.discord ?? null) as DiscordStatus | null; + const slack = (providers.slack ?? null) as SlackStatus | null; + const signal = (providers.signal ?? null) as SignalStatus | null; + const imessage = (providers.imessage ?? null) as IMessageStatus | null; switch (key) { case "whatsapp": return ( - snapshot.whatsapp.configured || - snapshot.whatsapp.linked || - snapshot.whatsapp.running + Boolean(whatsapp?.configured) || + Boolean(whatsapp?.linked) || + Boolean(whatsapp?.running) ); case "telegram": - return snapshot.telegram.configured || snapshot.telegram.running; + return Boolean(telegram?.configured) || Boolean(telegram?.running); case "discord": - return Boolean(snapshot.discord?.configured || snapshot.discord?.running); + return Boolean(discord?.configured || discord?.running); case "slack": - return Boolean(snapshot.slack?.configured || snapshot.slack?.running); + return Boolean(slack?.configured || slack?.running); case "signal": - return Boolean(snapshot.signal?.configured || snapshot.signal?.running); + return Boolean(signal?.configured || signal?.running); case "imessage": - return Boolean(snapshot.imessage?.configured || snapshot.imessage?.running); + return Boolean(imessage?.configured || imessage?.running); default: return false; } @@ -190,12 +210,12 @@ function renderProvider( key: ProviderKey, props: ConnectionsProps, data: { - whatsapp?: ProvidersStatusSnapshot["whatsapp"]; - telegram?: ProvidersStatusSnapshot["telegram"]; - discord?: ProvidersStatusSnapshot["discord"] | null; - slack?: ProvidersStatusSnapshot["slack"] | null; - signal?: ProvidersStatusSnapshot["signal"] | null; - imessage?: ProvidersStatusSnapshot["imessage"] | null; + whatsapp?: WhatsAppStatus; + telegram?: TelegramStatus; + discord?: DiscordStatus | null; + slack?: SlackStatus | null; + signal?: SignalStatus | null; + imessage?: IMessageStatus | null; }, ) { switch (key) { diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 53d7bf199..920941e1d 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -189,6 +189,7 @@ export function renderCron(props: CronProps) { +