Compare commits

..
Author SHA1 Message Date
Peter Steinberger dfea2991c9 fix(daemon): clear launchd disabled state before bootstrap (#849) (thanks @ndraiman) 2026-01-15 03:35:24 +00:00
Netanel Draiman d51a9ebb0e fix(daemon): enable launchd service before bootstrap
When a launchd service is uninstalled via bootout, macOS marks it as
disabled in /var/db/com.apple.xpc.launchd/disabled.*.plist. This persists
across reboots and plist recreation. Future bootstrap calls fail with
error 5 (I/O error) because the service is still marked disabled.

Fix: Call 'launchctl enable' before 'launchctl bootstrap' to clear the
disabled state, matching the fix already applied to the macOS Swift app
in GatewayLaunchAgentManager.swift.

Related: #306
2026-01-15 03:31:52 +00:00
Peter Steinberger 536d3d76a3 Merge pull request #925 from grp06/fix/console-eio
Logging: tolerate EIO console writes (AI)
2026-01-15 03:28:56 +00:00
Peter Steinberger 5c52dbf661 style: oxfmt fixes (#925) (thanks @grp06) 2026-01-15 03:22:54 +00:00
George Pickett 8f797f213e Logging: tolerate EIO console writes 2026-01-15 03:20:48 +00:00
Peter Steinberger ed68f378d7 Merge pull request #227 from Hyaxia/detect-secrets
add detect-secrets CI job + baseline guidance
2026-01-15 03:17:14 +00:00
Peter Steinberger 7e8de907f8 fix: note detect-secrets CI scan (#227) (thanks @Hyaxia) 2026-01-15 03:16:41 +00:00
hyaxia f3c9252840 Security: add detect-secrets scan 2026-01-15 03:14:43 +00:00
Peter Steinberger da9e27f466 docs: update changelog for telegram aggregation 2026-01-15 03:05:46 +00:00
Peter Steinberger aa74e28112 fix(telegram): aggregate split inbound messages 2026-01-15 03:04:59 +00:00
Peter SteinbergerandGeorge Pickett e569f15631 fix: scrub tuple items schemas for Gemini tools (#926) — thanks @grp06
Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 02:59:35 +00:00
Peter SteinbergerandAaron Konyer eaace34233 fix: restore docker binds and PATH in sandbox exec (#873)
Thanks @akonyer.

Co-authored-by: Aaron Konyer <aaronk@gomodular.ca>
2026-01-15 02:58:20 +00:00
Peter Steinberger 7a839e7eb6 docs: add messaging channel guide 2026-01-15 02:50:03 +00:00
Peter Steinberger 1b24b6a02b fix: add plugin runtime registry 2026-01-15 02:44:45 +00:00
Peter Steinberger 2b4a68e276 feat: load channel plugins 2026-01-15 02:42:44 +00:00
Peter Steinberger b1e3d79eaa docs: clarify Claude CLI auth mode 2026-01-15 02:35:20 +00:00
Peter SteinbergerandSebastian 2fb2035dbf fix: normalize Claude CLI auth mode to oauth (#855)
Thanks @sebslight.

Co-authored-by: Sebastian <sebslight@gmail.com>
2026-01-15 02:29:43 +00:00
Peter Steinberger 3c51290e0d Merge pull request #850 from evalexpr/fix/slack-top-level-require-mention
fix(slack): respect top-level requireMention config
2026-01-15 02:28:25 +00:00
Peter Steinberger 765196d5c3 style(slack): satisfy oxfmt (#850) 2026-01-15 02:23:22 +00:00
Peter SteinbergerandMarc Terns 12c2d37e62 docs: document DM history limits (#883)
Thanks @pkrmf.

Co-authored-by: Marc Terns <tenxurz@gmail.com>
2026-01-15 02:22:29 +00:00
87 changed files with 1934 additions and 171 deletions

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# detect-secrets exclusion patterns (regex)
#
# Note: detect-secrets does not read this file by default. If you want these
# applied, wire them into your scan command (e.g. translate to --exclude-files
# / --exclude-lines) or into a baseline's filters_used.
[exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$
[exclude-lines]
# Fastlane checks for private key marker; not a real key.
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
# UI label string for Anthropic auth mode.
pattern = case \.apiKeyEnv: "API key \(env var\)"
# CodingKeys mapping uses apiKey literal.
pattern = case apikey = "apiKey"
# Schema labels referencing password fields (not actual secrets).
pattern = "gateway\.remote\.password"
pattern = "gateway\.auth\.password"
# Schema label for talk API key (label text only).
pattern = "talk\.apiKey"
# checking for typeof is not something we care about.
pattern = === "string"
# specific optional-chaining password check that didn't match the line above.
pattern = typeof remote\?\.password === "string"
+25
View File
@@ -141,6 +141,31 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }}) - name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }} run: ${{ matrix.command }}
secrets:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install detect-secrets
run: |
python -m pip install --upgrade pip
python -m pip install detect-secrets==1.5.0
- name: Detect secrets
run: |
if ! detect-secrets scan --baseline .secrets.baseline; then
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
exit 1
fi
checks-windows: checks-windows:
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
defaults: defaults:
+518
View File
@@ -0,0 +1,518 @@
{
"version": "1.5.0",
"plugins_used": [
{
"name": "ArtifactoryDetector"
},
{
"name": "AWSKeyDetector"
},
{
"name": "AzureStorageKeyDetector"
},
{
"name": "Base64HighEntropyString",
"limit": 4.5
},
{
"name": "BasicAuthDetector"
},
{
"name": "CloudantDetector"
},
{
"name": "DiscordBotTokenDetector"
},
{
"name": "GitHubTokenDetector"
},
{
"name": "GitLabTokenDetector"
},
{
"name": "HexHighEntropyString",
"limit": 3.0
},
{
"name": "IbmCloudIamDetector"
},
{
"name": "IbmCosHmacDetector"
},
{
"name": "IPPublicDetector"
},
{
"name": "JwtTokenDetector"
},
{
"name": "KeywordDetector",
"keyword_exclude": ""
},
{
"name": "MailchimpDetector"
},
{
"name": "NpmDetector"
},
{
"name": "OpenAIDetector"
},
{
"name": "PrivateKeyDetector"
},
{
"name": "PypiTokenDetector"
},
{
"name": "SendGridDetector"
},
{
"name": "SlackDetector"
},
{
"name": "SoftlayerDetector"
},
{
"name": "SquareOAuthDetector"
},
{
"name": "StripeDetector"
},
{
"name": "TelegramBotTokenDetector"
},
{
"name": "TwilioKeyDetector"
}
],
"filters_used": [
{
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
"min_level": 2
},
{
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
},
{
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
},
{
"path": "detect_secrets.filters.heuristic.is_lock_file"
},
{
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
},
{
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
},
{
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
},
{
"path": "detect_secrets.filters.heuristic.is_sequential_string"
},
{
"path": "detect_secrets.filters.heuristic.is_swagger_file"
},
{
"path": "detect_secrets.filters.heuristic.is_templated_secret"
},
{
"path": "detect_secrets.filters.regex.should_exclude_file",
"pattern": [
"(^|/)pnpm-lock\\.yaml$"
]
},
{
"path": "detect_secrets.filters.regex.should_exclude_line",
"pattern": [
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
"case apikey = \"apiKey\"",
"\"gateway\\.remote\\.password\"",
"\"gateway\\.auth\\.password\"",
"\"talk\\.apiKey\"",
"=== \"string\"",
"typeof remote\\?\\.password === \"string\""
]
}
],
"results": {
".env.example": [
{
"type": "Twilio API Key",
"filename": ".env.example",
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
"is_verified": false,
"line_number": 2
}
],
"appcast.xml": [
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
"is_verified": false,
"line_number": 19
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
"is_verified": false,
"line_number": 52
}
],
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
"is_verified": false,
"line_number": 26
},
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
"is_verified": false,
"line_number": 42
}
],
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 83
}
],
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
{
"type": "Secret Keyword",
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 27
}
],
"docs/configuration.md": [
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 268
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 465
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 718
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 760
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 859
},
{
"type": "Secret Keyword",
"filename": "docs/configuration.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 982
}
],
"docs/faq.md": [
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 593
},
{
"type": "Secret Keyword",
"filename": "docs/faq.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 650
}
],
"docs/skills-config.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills-config.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 28
}
],
"docs/skills.md": [
{
"type": "Secret Keyword",
"filename": "docs/skills.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 97
}
],
"docs/tailscale.md": [
{
"type": "Secret Keyword",
"filename": "docs/tailscale.md",
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
"is_verified": false,
"line_number": 52
}
],
"docs/talk.md": [
{
"type": "Secret Keyword",
"filename": "docs/talk.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 50
}
],
"docs/telegram.md": [
{
"type": "Secret Keyword",
"filename": "docs/telegram.md",
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
"is_verified": false,
"line_number": 57
}
],
"skills/local-places/SERVER_README.md": [
{
"type": "Secret Keyword",
"filename": "skills/local-places/SERVER_README.md",
"hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3",
"is_verified": false,
"line_number": 28
}
],
"skills/openai-whisper-api/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/openai-whisper-api/SKILL.md",
"hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03",
"is_verified": false,
"line_number": 39
}
],
"skills/trello/SKILL.md": [
{
"type": "Secret Keyword",
"filename": "skills/trello/SKILL.md",
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
"is_verified": false,
"line_number": 18
}
],
"src/agents/models-config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 25
},
{
"type": "Secret Keyword",
"filename": "src/agents/models-config.test.ts",
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
"is_verified": false,
"line_number": 90
}
],
"src/agents/skills.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
"is_verified": false,
"line_number": 158
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
"is_verified": false,
"line_number": 265
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
"is_verified": false,
"line_number": 462
},
{
"type": "Secret Keyword",
"filename": "src/agents/skills.test.ts",
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
"is_verified": false,
"line_number": 490
}
],
"src/browser/target-id.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/browser/target-id.test.ts",
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
"is_verified": false,
"line_number": 16
}
],
"src/commands/antigravity-oauth.ts": [
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
"is_verified": false,
"line_number": 17
},
{
"type": "Base64 High Entropy String",
"filename": "src/commands/antigravity-oauth.ts",
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
"is_verified": false,
"line_number": 20
}
],
"src/commands/onboard-auth.ts": [
{
"type": "Secret Keyword",
"filename": "src/commands/onboard-auth.ts",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 50
}
],
"src/config/config.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/config/config.test.ts",
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
"is_verified": false,
"line_number": 520
}
],
"src/gateway/server.auth.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
"is_verified": false,
"line_number": 89
},
{
"type": "Secret Keyword",
"filename": "src/gateway/server.auth.test.ts",
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
"is_verified": false,
"line_number": 109
}
],
"src/infra/env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
"is_verified": false,
"line_number": 10
},
{
"type": "Secret Keyword",
"filename": "src/infra/env.test.ts",
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
"is_verified": false,
"line_number": 25
}
],
"src/infra/shell-env.test.ts": [
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
"is_verified": false,
"line_number": 35
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
"is_verified": false,
"line_number": 56
},
{
"type": "Secret Keyword",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
"is_verified": false,
"line_number": 73
},
{
"type": "Base64 High Entropy String",
"filename": "src/infra/shell-env.test.ts",
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
"is_verified": false,
"line_number": 75
}
],
"src/web/qr-image.test.ts": [
{
"type": "Hex High Entropy String",
"filename": "src/web/qr-image.test.ts",
"hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e",
"is_verified": false,
"line_number": 12
}
],
"vendor/a2ui/README.md": [
{
"type": "Secret Keyword",
"filename": "vendor/a2ui/README.md",
"hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6",
"is_verified": false,
"line_number": 123
}
]
},
"generated_at": "2026-01-05T13:01:00Z"
}
+9
View File
@@ -6,10 +6,19 @@
- Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`. - Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist. - Docs: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
### Fixes ### Fixes
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
## 2026.1.14 ## 2026.1.14
+1
View File
@@ -300,6 +300,7 @@ ack reaction after the bot replies.
- `maxLinesPerMessage`: soft max line count per message. Default: 17. - `maxLinesPerMessage`: soft max line count per message. Default: 17.
- `mediaMaxMb`: clamp inbound media saved to disk. - `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables). - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
- `dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `dms["<user_id>"].historyLimit`.
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter). - `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
- `actions`: per-action tool gates; omit to allow all (set `false` to disable). - `actions`: per-action tool gates; omit to allow all (set `false` to disable).
- `reactions` (covers react + read reactions) - `reactions` (covers react + read reactions)
+1
View File
@@ -189,6 +189,7 @@ Provider options:
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.imessage.groupAllowFrom`: group sender allowlist. - `channels.imessage.groupAllowFrom`: group sender allowlist.
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables). - `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
- `channels.imessage.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.imessage.dms["<handle>"].historyLimit`.
- `channels.imessage.groups`: per-group defaults + allowlist (use `"*"` for global defaults). - `channels.imessage.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
- `channels.imessage.includeAttachments`: ingest attachments into context. - `channels.imessage.includeAttachments`: ingest attachments into context.
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
+1
View File
@@ -208,6 +208,7 @@ This is often easier than hand-editing JSON manifests.
## History context ## History context
- `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt. - `channels.msteams.historyLimit` controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- DM history can be limited with `channels.msteams.dmHistoryLimit` (user turns). Per-user overrides: `channels.msteams.dms["<user_id>"].historyLimit`.
## Current Teams RSC Permissions (Manifest) ## Current Teams RSC Permissions (Manifest)
These are the **existing resourceSpecific permissions** in our Teams app manifest. They only apply inside the team/chat where the app is installed. These are the **existing resourceSpecific permissions** in our Teams app manifest. They only apply inside the team/chat where the app is installed.
+1
View File
@@ -124,6 +124,7 @@ Provider options:
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.signal.groupAllowFrom`: group sender allowlist. - `channels.signal.groupAllowFrom`: group sender allowlist.
- `channels.signal.historyLimit`: max group messages to include as context (0 disables). - `channels.signal.historyLimit`: max group messages to include as context (0 disables).
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
- `channels.signal.textChunkLimit`: outbound chunk size (chars). - `channels.signal.textChunkLimit`: outbound chunk size (chars).
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
+1
View File
@@ -66,6 +66,7 @@ Or via config:
## History context ## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. - `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- DM history can be limited with `channels.slack.dmHistoryLimit` (user turns). Per-user overrides: `channels.slack.dms["<user_id>"].historyLimit`.
## Config writes ## Config writes
By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`. By default, Slack is allowed to write config updates triggered by channel events or `/config set|unset`.
+1
View File
@@ -105,6 +105,7 @@ group messages, so use admin if you need full visibility.
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5). - Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs. - Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
- DM history can be limited with `channels.telegram.dmHistoryLimit` (user turns). Per-user overrides: `channels.telegram.dms["<user_id>"].historyLimit`.
## Group activation modes ## Group activation modes
+1
View File
@@ -292,6 +292,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
- `channels.whatsapp.groupAllowFrom` (group sender allowlist). - `channels.whatsapp.groupAllowFrom` (group sender allowlist).
- `channels.whatsapp.groupPolicy` (group policy). - `channels.whatsapp.groupPolicy` (group policy).
- `channels.whatsapp.historyLimit` / `channels.whatsapp.accounts.<accountId>.historyLimit` (group history context; `0` disables). - `channels.whatsapp.historyLimit` / `channels.whatsapp.accounts.<accountId>.historyLimit` (group history context; `0` disables).
- `channels.whatsapp.dmHistoryLimit` (DM history limit in user turns). Per-user overrides: `channels.whatsapp.dms["<phone>"].historyLimit`.
- `channels.whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `channels.whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all)
- `channels.whatsapp.actions.reactions` (gate WhatsApp tool reactions). - `channels.whatsapp.actions.reactions` (gate WhatsApp tool reactions).
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`)
+4
View File
@@ -67,6 +67,10 @@ clawdbot doctor
This should create (or refresh) an auth profile like `anthropic:claude-cli` in This should create (or refresh) an auth profile like `anthropic:claude-cli` in
the agent auth store. the agent auth store.
Clawdbot config sets `auth.profiles["anthropic:claude-cli"].mode` to `"oauth"` so
the profile accepts both OAuth and setup-token credentials. Older configs that
used `"token"` are auto-migrated on load.
If you see an Anthropic error like: If you see an Anthropic error like:
``` ```
+28
View File
@@ -306,6 +306,10 @@ rotation order used for failover.
} }
``` ```
Note: `anthropic:claude-cli` should use `mode: "oauth"` even when the stored
credential is a setup-token. Clawdbot auto-migrates older configs that used
`mode: "token"`.
### `agents.list[].identity` ### `agents.list[].identity`
Optional per-agent identity used for defaults and UX. This is written by the macOS onboarding assistant. Optional per-agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
@@ -479,6 +483,30 @@ Group messages default to **require mention** (either metadata mention or regex
`messages.groupChat.historyLimit` sets the global default for group history context. Channels can override with `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit` for multi-account). Set `0` to disable history wrapping. `messages.groupChat.historyLimit` sets the global default for group history context. Channels can override with `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit` for multi-account). Set `0` to disable history wrapping.
#### DM history limits
DM conversations use session-based history managed by the agent. You can limit the number of user turns retained per DM session:
```json5
{
channels: {
telegram: {
dmHistoryLimit: 30, // limit DM sessions to 30 user turns
dms: {
"123456789": { historyLimit: 50 } // per-user override (user ID)
}
}
}
}
```
Resolution order:
1. Per-DM override: `channels.<provider>.dms[userId].historyLimit`
2. Provider default: `channels.<provider>.dmHistoryLimit`
3. No limit (all history retained)
Supported providers: `telegram`, `whatsapp`, `discord`, `slack`, `signal`, `imessage`, `msteams`.
Per-agent override (takes precedence when set, even `[]`): Per-agent override (takes precedence when set, even `[]`):
```json5 ```json5
{ {
+26
View File
@@ -439,6 +439,32 @@ If your AI does something bad:
- What the attacker sent + what the agent did - What the attacker sent + what the agent did
- Whether the Gateway was exposed beyond loopback (LAN/Tailscale Funnel/Serve) - Whether the Gateway was exposed beyond loopback (LAN/Tailscale Funnel/Serve)
## Secret Scanning (detect-secrets)
CI runs `detect-secrets scan --baseline .secrets.baseline` in the `secrets` job.
If it fails, there are new candidates not yet in the baseline.
### If CI fails
1. Reproduce locally:
```bash
detect-secrets scan --baseline .secrets.baseline
```
2. Understand the tools:
- `detect-secrets scan` finds candidates and compares them to the baseline.
- `detect-secrets audit` opens an interactive review to mark each baseline
item as real or false positive.
3. For real secrets: rotate/remove them, then re-run the scan to update the baseline.
4. For false positives: run the interactive audit and mark them as false:
```bash
detect-secrets audit .secrets.baseline
```
5. If you need new excludes, add them to `.detect-secrets.cfg` and regenerate the
baseline with matching `--exclude-files` / `--exclude-lines` flags (the config
file is reference-only; detect-secrets doesn’t read it automatically).
Commit the updated `.secrets.baseline` once it reflects the intended state.
## The Trust Hierarchy ## The Trust Hierarchy
``` ```
+5
View File
@@ -31,6 +31,11 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
## Common Issues ## Common Issues
### CI Secrets Scan Failed
This means `detect-secrets` found new candidates not yet in the baseline.
Follow [Secret scanning](/gateway/security#secret-scanning-detect-secrets).
### Service Installed but Nothing is Running ### Service Installed but Nothing is Running
If the gateway service is installed but the process exits immediately, the daemon If the gateway service is installed but the process exits immediately, the daemon
+4
View File
@@ -434,3 +434,7 @@ Example:
- Container not running: it will auto-create per session on demand. - Container not running: it will auto-create per session on demand.
- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your - Permission errors in sandbox: set `docker.user` to a UID:GID that matches your
mounted workspace ownership (or chown the workspace folder). mounted workspace ownership (or chown the workspace folder).
- Custom tools not found: Clawdbot runs commands with `sh -lc` (login shell), which
sources `/etc/profile` and may reset PATH. Set `docker.env.PATH` to prepend your
custom tool paths (e.g., `/custom/bin:/usr/local/share/npm-global/bin`), or add
a script under `/etc/profile.d/` in your Dockerfile.
+115
View File
@@ -165,6 +165,121 @@ Plugins export either:
- A function: `(api) => { ... }` - A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }` - An object: `{ id, name, configSchema, register(api) { ... } }`
### Register a messaging channel
Plugins can register **channel plugins** that behave like built‑in channels
(WhatsApp, Telegram, etc.). Channel config lives under `channels.<id>` and is
validated by your channel plugin code.
```ts
const myChannel = {
id: "acmechat",
meta: {
id: "acmechat",
label: "AcmeChat",
selectionLabel: "AcmeChat (API)",
docsPath: "/channels/acmechat",
blurb: "demo channel plugin.",
aliases: ["acme"],
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
resolveAccount: (cfg, accountId) =>
(cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? { accountId }),
},
outbound: {
deliveryMode: "direct",
sendText: async () => ({ ok: true }),
},
};
export default function (api) {
api.registerChannel({ plugin: myChannel });
}
```
Notes:
- Put config under `channels.<id>` (not `plugins.entries`).
- `meta.label` is used for labels in CLI/UI lists.
- `meta.aliases` adds alternate ids for normalization and CLI inputs.
### Write a new messaging channel (step‑by‑step)
Use this when you want a **new chat surface** (a “messaging channel”), not a model provider.
Model provider docs live under `/providers/*`.
1) Pick an id + config shape
- All channel config lives under `channels.<id>`.
- Prefer `channels.<id>.accounts.<accountId>` for multi‑account setups.
2) Define the channel metadata
- `meta.label`, `meta.selectionLabel`, `meta.docsPath`, `meta.blurb` control CLI/UI lists.
- `meta.docsPath` should point at a docs page like `/channels/<id>`.
3) Implement the required adapters
- `config.listAccountIds` + `config.resolveAccount`
- `capabilities` (chat types, media, threads, etc.)
- `outbound.deliveryMode` + `outbound.sendText` (for basic send)
4) Add optional adapters as needed
- `setup` (wizard), `security` (DM policy), `status` (health/diagnostics)
- `gateway` (start/stop/login), `mentions`, `threading`, `streaming`
- `actions` (message actions), `commands` (native command behavior)
5) Register the channel in your plugin
- `api.registerChannel({ plugin })`
Minimal config example:
```json5
{
channels: {
acmechat: {
accounts: {
default: { token: "ACME_TOKEN", enabled: true }
}
}
}
}
```
Minimal channel plugin (outbound‑only):
```ts
const plugin = {
id: "acmechat",
meta: {
id: "acmechat",
label: "AcmeChat",
selectionLabel: "AcmeChat (API)",
docsPath: "/channels/acmechat",
blurb: "AcmeChat messaging channel.",
aliases: ["acme"],
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg) => Object.keys(cfg.channels?.acmechat?.accounts ?? {}),
resolveAccount: (cfg, accountId) =>
(cfg.channels?.acmechat?.accounts?.[accountId ?? "default"] ?? { accountId }),
},
outbound: {
deliveryMode: "direct",
sendText: async ({ text }) => {
// deliver `text` to your channel here
return { ok: true };
},
},
};
export default function (api) {
api.registerChannel({ plugin });
}
```
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
then configure `channels.<id>` in your config.
### Register a tool ### Register a tool
```ts ```ts
+3
View File
@@ -59,4 +59,7 @@ clawdbot onboard --auth-choice claude-cli
## Notes ## Notes
- The wizard can run `claude setup-token` on the gateway host and store the token. - The wizard can run `claude setup-token` on the gateway host and store the token.
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
auto-migrated on load.
- Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth). - Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth).
+4
View File
@@ -239,6 +239,10 @@ It also warns if your configured model is unknown or missing auth.
The wizard can run `claude setup-token` on the gateway host (or you run it yourself), then stores the token as an auth profile for the **anthropic** provider. That profile is used for model calls the same way an API key or OAuth profile would be. If you already ran `claude setup-token`, pick **Anthropic token (paste setup-token)** and paste it. More detail: [OAuth](/concepts/oauth). The wizard can run `claude setup-token` on the gateway host (or you run it yourself), then stores the token as an auth profile for the **anthropic** provider. That profile is used for model calls the same way an API key or OAuth profile would be. If you already ran `claude setup-token`, pick **Anthropic token (paste setup-token)** and paste it. More detail: [OAuth](/concepts/oauth).
Clawdbot keeps `auth.profiles["anthropic:claude-cli"].mode` set to `"oauth"` so
the profile accepts both OAuth and setup-token credentials; older `"token"` mode
entries auto-migrate.
### Do you support Claude subscription auth (Claude Code OAuth)? ### Do you support Claude subscription auth (Claude Code OAuth)?
Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** on the gateway host for the most reliable long‑running setup (requires Claude Pro/Max + the `claude` CLI). OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth). Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** on the gateway host for the most reliable long‑running setup (requires Claude Pro/Max + the `claude` CLI). OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
@@ -154,4 +154,81 @@ describe("resolveAuthProfileOrder", () => {
}); });
expect(order).toEqual(["anthropic:work", "anthropic:default"]); expect(order).toEqual(["anthropic:work", "anthropic:default"]);
}); });
it("mode: oauth config accepts both oauth and token credentials (issue #559)", () => {
const now = Date.now();
const storeWithBothTypes: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:oauth-cred": {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: now + 60_000,
},
"anthropic:token-cred": {
type: "token",
provider: "anthropic",
token: "just-a-token",
expires: now + 60_000,
},
},
};
const orderOauthCred = resolveAuthProfileOrder({
store: storeWithBothTypes,
provider: "anthropic",
cfg: {
auth: {
profiles: {
"anthropic:oauth-cred": { provider: "anthropic", mode: "oauth" },
},
},
},
});
expect(orderOauthCred).toContain("anthropic:oauth-cred");
const orderTokenCred = resolveAuthProfileOrder({
store: storeWithBothTypes,
provider: "anthropic",
cfg: {
auth: {
profiles: {
"anthropic:token-cred": { provider: "anthropic", mode: "oauth" },
},
},
},
});
expect(orderTokenCred).toContain("anthropic:token-cred");
});
it("mode: token config rejects oauth credentials (issue #559 root cause)", () => {
const now = Date.now();
const storeWithOauth: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:oauth-cred": {
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: now + 60_000,
},
},
};
const order = resolveAuthProfileOrder({
store: storeWithOauth,
provider: "anthropic",
cfg: {
auth: {
profiles: {
"anthropic:oauth-cred": { provider: "anthropic", mode: "token" },
},
},
},
});
expect(order).not.toContain("anthropic:oauth-cred");
});
}); });
+6 -1
View File
@@ -60,7 +60,12 @@ export function buildDockerExecArgs(params: {
for (const [key, value] of Object.entries(params.env)) { for (const [key, value] of Object.entries(params.env)) {
args.push("-e", `${key}=${value}`); args.push("-e", `${key}=${value}`);
} }
args.push(params.containerName, "sh", "-lc", params.command); // Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
// overriding both Docker ENV and -e PATH=... environment variables.
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
// while preserving system paths that /etc/profile may have added.
const pathExport = params.env.PATH ? `export PATH="${params.env.PATH}:$PATH"; ` : "";
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
return args; return args;
} }
+71
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry.js"; import { resetProcessRegistryForTests } from "./bash-process-registry.js";
import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js"; import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js";
import { buildDockerExecArgs } from "./bash-tools.shared.js";
import { sanitizeBinaryOutput } from "./shell-utils.js"; import { sanitizeBinaryOutput } from "./shell-utils.js";
const isWin = process.platform === "win32"; const isWin = process.platform === "win32";
@@ -239,3 +240,73 @@ describe("exec tool backgrounding", () => {
expect(pollB.details.status).toBe("failed"); expect(pollB.details.status).toBe("failed");
}); });
}); });
describe("buildDockerExecArgs", () => {
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
const args = buildDockerExecArgs({
containerName: "test-container",
command: "echo hello",
env: {
PATH: "/custom/bin:/usr/local/bin:/usr/bin",
HOME: "/home/user",
},
tty: false,
});
const commandArg = args[args.length - 1];
expect(commandArg).toContain('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"');
expect(commandArg).toContain("echo hello");
expect(commandArg).toBe('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"; echo hello');
});
it("does not add PATH export when PATH is not in env", () => {
const args = buildDockerExecArgs({
containerName: "test-container",
command: "echo hello",
env: {
HOME: "/home/user",
},
tty: false,
});
const commandArg = args[args.length - 1];
expect(commandArg).toBe("echo hello");
expect(commandArg).not.toContain("export PATH");
});
it("includes workdir flag when specified", () => {
const args = buildDockerExecArgs({
containerName: "test-container",
command: "pwd",
workdir: "/workspace",
env: { HOME: "/home/user" },
tty: false,
});
expect(args).toContain("-w");
expect(args).toContain("/workspace");
});
it("uses login shell for consistent environment", () => {
const args = buildDockerExecArgs({
containerName: "test-container",
command: "echo test",
env: { HOME: "/home/user" },
tty: false,
});
expect(args).toContain("sh");
expect(args).toContain("-lc");
});
it("includes tty flag when requested", () => {
const args = buildDockerExecArgs({
containerName: "test-container",
command: "bash",
env: { HOME: "/home/user" },
tty: true,
});
expect(args).toContain("-t");
});
});
@@ -156,6 +156,30 @@ describe("createClawdbotCodingTools", () => {
enum: ["a", "b"], enum: ["a", "b"],
}); });
}); });
it("cleans tuple items schemas", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
tuples: {
type: "array",
items: [
{ type: "string", format: "uuid" },
{ type: "number", minimum: 1 },
],
},
},
}) as {
properties?: Record<string, unknown>;
};
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
const first = items[0] as { format?: unknown } | undefined;
const second = items[1] as { minimum?: unknown } | undefined;
expect(first?.format).toBeUndefined();
expect(second?.minimum).toBeUndefined();
});
it("drops null-only union variants without flattening other unions", () => { it("drops null-only union variants without flattening other unions", () => {
const cleaned = __testing.cleanToolSchemaForGemini({ const cleaned = __testing.cleanToolSchemaForGemini({
type: "object", type: "object",
+10 -2
View File
@@ -280,8 +280,16 @@ function cleanSchemaForGeminiWithDefs(
cleanSchemaForGeminiWithDefs(v, nextDefs, refStack), cleanSchemaForGeminiWithDefs(v, nextDefs, refStack),
]), ]),
); );
} else if (key === "items" && value && typeof value === "object") { } else if (key === "items" && value) {
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack); if (Array.isArray(value)) {
cleaned[key] = value.map((entry) =>
cleanSchemaForGeminiWithDefs(entry, nextDefs, refStack),
);
} else if (typeof value === "object") {
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
} else {
cleaned[key] = value;
}
} else if (key === "anyOf" && Array.isArray(value)) { } else if (key === "anyOf" && Array.isArray(value)) {
cleaned[key] = cleaned[key] =
cleanedAnyOf ?? cleanedAnyOf ??
+3 -4
View File
@@ -1,10 +1,8 @@
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { CHANNEL_IDS } from "../channels/registry.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
const MESSAGE_CHANNEL_OPTIONS = CHANNEL_IDS.join("|");
export function buildAgentSystemPrompt(params: { export function buildAgentSystemPrompt(params: {
workspaceDir: string; workspaceDir: string;
defaultThinkLevel?: ThinkLevel; defaultThinkLevel?: ThinkLevel;
@@ -169,6 +167,7 @@ export function buildAgentSystemPrompt(params: {
.filter(Boolean); .filter(Boolean);
const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase())); const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase()));
const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons");
const messageChannelOptions = listDeliverableMessageChannels().join("|");
const skillsLines = skillsPrompt ? [skillsPrompt, ""] : []; const skillsLines = skillsPrompt ? [skillsPrompt, ""] : [];
const skillsSection = skillsPrompt const skillsSection = skillsPrompt
? [ ? [
@@ -319,7 +318,7 @@ export function buildAgentSystemPrompt(params: {
"### message tool", "### message tool",
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).", "- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
"- For `action=send`, include `to` and `message`.", "- For `action=send`, include `to` and `message`.",
`- If multiple channels are configured, pass \`channel\` (${MESSAGE_CHANNEL_OPTIONS}).`, `- If multiple channels are configured, pass \`channel\` (${messageChannelOptions}).`,
inlineButtonsEnabled inlineButtonsEnabled
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
: runtimeChannel : runtimeChannel
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ChannelDock } from "../channels/dock.js"; import type { ChannelDock } from "../channels/dock.js";
import { getChannelDock, listChannelDocks } from "../channels/dock.js"; import { getChannelDock, listChannelDocks } from "../channels/dock.js";
import type { ChannelId } from "../channels/plugins/types.js"; import type { ChannelId } from "../channels/plugins/types.js";
import { normalizeChannelId } from "../channels/registry.js"; import { normalizeChannelId } from "../channels/plugins/index.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { MsgContext } from "./templating.js"; import type { MsgContext } from "./templating.js";
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NormalizedUsage } from "../../agents/usage.js"; import type { NormalizedUsage } from "../../agents/usage.js";
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js";
import { normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/plugins/index.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js";
+8 -7
View File
@@ -1,23 +1,24 @@
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import { CHANNEL_IDS, normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/plugins/index.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { BlockStreamingCoalesceConfig } from "../../config/types.js"; import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
import { normalizeAccountId } from "../../routing/session-key.js"; import { normalizeAccountId } from "../../routing/session-key.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import {
INTERNAL_MESSAGE_CHANNEL,
listDeliverableMessageChannels,
} from "../../utils/message-channel.js";
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MIN = 800;
const DEFAULT_BLOCK_STREAM_MAX = 1200; const DEFAULT_BLOCK_STREAM_MAX = 1200;
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000; const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([ const getBlockChunkProviders = () =>
...CHANNEL_IDS, new Set<TextChunkProvider>([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]);
INTERNAL_MESSAGE_CHANNEL,
]);
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined { function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
if (!provider) return undefined; if (!provider) return undefined;
const cleaned = provider.trim().toLowerCase(); const cleaned = provider.trim().toLowerCase();
return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider) return getBlockChunkProviders().has(cleaned as TextChunkProvider)
? (cleaned as TextChunkProvider) ? (cleaned as TextChunkProvider)
: undefined; : undefined;
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import { getChatChannelMeta, normalizeChannelId } from "../../channels/registry.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js"; import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js";
@@ -50,7 +50,7 @@ export function buildGroupIntro(params: {
const providerLabel = (() => { const providerLabel = (() => {
if (!providerKey) return "chat"; if (!providerKey) return "chat";
if (isInternalMessageChannel(providerKey)) return "WebChat"; if (isInternalMessageChannel(providerKey)) return "WebChat";
if (providerId) return getChatChannelMeta(providerId).label; if (providerId) return getChannelPlugin(providerId)?.meta.label ?? providerId;
return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`; return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`;
})(); })();
const subjectLine = subject const subjectLine = subject
+1 -1
View File
@@ -1,6 +1,6 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { resolveAgentConfig } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/plugins/index.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
+2 -1
View File
@@ -1,6 +1,7 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js"; import { resolveAgentConfig } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import { CHAT_CHANNEL_ORDER, normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/plugins/index.js";
import { CHAT_CHANNEL_ORDER } from "../../channels/registry.js";
import type { AgentElevatedAllowFromConfig, ClawdbotConfig } from "../../config/config.js"; import type { AgentElevatedAllowFromConfig, ClawdbotConfig } from "../../config/config.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/plugins/index.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { ReplyToMode } from "../../config/types.js"; import type { ReplyToMode } from "../../config/types.js";
import type { OriginatingChannelType } from "../templating.js"; import type { OriginatingChannelType } from "../templating.js";
+1 -1
View File
@@ -9,7 +9,7 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/plugins/index.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import type { OriginatingChannelType } from "../templating.js"; import type { OriginatingChannelType } from "../templating.js";
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/plugins/index.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { import {
buildGroupDisplayName, buildGroupDisplayName,
+67 -4
View File
@@ -7,6 +7,7 @@ import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveWhatsAppAccount } from "../web/accounts.js";
import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js"; import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { import {
resolveDiscordGroupRequireMention, resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention, resolveIMessageGroupRequireMention,
@@ -21,9 +22,10 @@ import type {
ChannelGroupAdapter, ChannelGroupAdapter,
ChannelId, ChannelId,
ChannelMentionAdapter, ChannelMentionAdapter,
ChannelPlugin,
ChannelThreadingAdapter, ChannelThreadingAdapter,
} from "./plugins/types.js"; } from "./plugins/types.js";
import { CHAT_CHANNEL_ORDER } from "./registry.js"; import { CHAT_CHANNEL_ORDER, type ChatChannelId, getChatChannelMeta } from "./registry.js";
export type ChannelDock = { export type ChannelDock = {
id: ChannelId; id: ChannelId;
@@ -75,7 +77,7 @@ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\
// Adding a channel: // Adding a channel:
// - add a new entry to `DOCKS` // - add a new entry to `DOCKS`
// - keep it cheap; push heavy logic into `src/channels/plugins/<id>.ts` or channel modules // - keep it cheap; push heavy logic into `src/channels/plugins/<id>.ts` or channel modules
const DOCKS: Record<ChannelId, ChannelDock> = { const DOCKS: Record<ChatChannelId, ChannelDock> = {
telegram: { telegram: {
id: "telegram", id: "telegram",
capabilities: { capabilities: {
@@ -311,10 +313,71 @@ const DOCKS: Record<ChannelId, ChannelDock> = {
}, },
}; };
function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock {
return {
id: plugin.id,
capabilities: plugin.capabilities,
commands: plugin.commands,
outbound: plugin.outbound?.textChunkLimit
? { textChunkLimit: plugin.outbound.textChunkLimit }
: undefined,
streaming: plugin.streaming
? { blockStreamingCoalesceDefaults: plugin.streaming.blockStreamingCoalesceDefaults }
: undefined,
elevated: plugin.elevated,
config: plugin.config
? {
resolveAllowFrom: plugin.config.resolveAllowFrom,
formatAllowFrom: plugin.config.formatAllowFrom,
}
: undefined,
groups: plugin.groups,
mentions: plugin.mentions,
threading: plugin.threading,
};
}
function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> {
const registry = getActivePluginRegistry();
if (!registry) return [];
const entries: Array<{ id: ChannelId; dock: ChannelDock; order?: number }> = [];
const seen = new Set<string>();
for (const entry of registry.channels) {
const plugin = entry.plugin;
const id = String(plugin.id).trim();
if (!id || seen.has(id)) continue;
seen.add(id);
if (CHAT_CHANNEL_ORDER.includes(plugin.id as ChatChannelId)) continue;
const dock = entry.dock ?? buildDockFromPlugin(plugin);
entries.push({ id: plugin.id, dock, order: plugin.meta.order });
}
return entries;
}
export function listChannelDocks(): ChannelDock[] { export function listChannelDocks(): ChannelDock[] {
return CHAT_CHANNEL_ORDER.map((id) => DOCKS[id]); const baseEntries = CHAT_CHANNEL_ORDER.map((id) => ({
id,
dock: DOCKS[id],
order: getChatChannelMeta(id).order,
}));
const pluginEntries = listPluginDockEntries();
const combined = [...baseEntries, ...pluginEntries];
combined.sort((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.order ?? (indexA === -1 ? 999 : indexA);
const orderB = b.order ?? (indexB === -1 ? 999 : indexB);
if (orderA !== orderB) return orderA - orderB;
return String(a.id).localeCompare(String(b.id));
});
return combined.map((entry) => entry.dock);
} }
export function getChannelDock(id: ChannelId): ChannelDock | undefined { export function getChannelDock(id: ChannelId): ChannelDock | undefined {
return DOCKS[id]; const core = DOCKS[id as ChatChannelId];
if (core) return core;
const registry = getActivePluginRegistry();
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
if (!pluginEntry) return undefined;
return pluginEntry.dock ?? buildDockFromPlugin(pluginEntry.plugin);
} }
+5 -7
View File
@@ -3,12 +3,10 @@ import { CHANNEL_IDS } from "../registry.js";
import { listChannelPlugins } from "./index.js"; import { listChannelPlugins } from "./index.js";
describe("channel plugin registry", () => { describe("channel plugin registry", () => {
it("stays in sync with channel ids", () => { it("includes the built-in channel ids", () => {
const pluginIds = listChannelPlugins() const pluginIds = listChannelPlugins().map((plugin) => plugin.id);
.map((plugin) => plugin.id) for (const id of CHANNEL_IDS) {
.slice() expect(pluginIds).toContain(id);
.sort(); }
const channelIds = [...CHANNEL_IDS].slice().sort();
expect(pluginIds).toEqual(channelIds);
}); });
}); });
+35 -4
View File
@@ -7,6 +7,7 @@ import { slackPlugin } from "./slack.js";
import { telegramPlugin } from "./telegram.js"; import { telegramPlugin } from "./telegram.js";
import type { ChannelId, ChannelPlugin } from "./types.js"; import type { ChannelId, ChannelPlugin } from "./types.js";
import { whatsappPlugin } from "./whatsapp.js"; import { whatsappPlugin } from "./whatsapp.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
// Channel plugins registry (runtime). // Channel plugins registry (runtime).
// //
@@ -18,7 +19,7 @@ import { whatsappPlugin } from "./whatsapp.js";
// - add `<id>Plugin` import + entry in `resolveChannels()` // - add `<id>Plugin` import + entry in `resolveChannels()`
// - add an entry to `src/channels/dock.ts` for shared behavior (capabilities, allowFrom, threading, …) // - add an entry to `src/channels/dock.ts` for shared behavior (capabilities, allowFrom, threading, …)
// - add ids/aliases in `src/channels/registry.ts` // - add ids/aliases in `src/channels/registry.ts`
function resolveChannels(): ChannelPlugin[] { function resolveCoreChannels(): ChannelPlugin[] {
return [ return [
telegramPlugin, telegramPlugin,
whatsappPlugin, whatsappPlugin,
@@ -30,8 +31,27 @@ function resolveChannels(): ChannelPlugin[] {
]; ];
} }
function listPluginChannels(): ChannelPlugin[] {
const registry = getActivePluginRegistry();
if (!registry) return [];
return registry.channels.map((entry) => entry.plugin);
}
function dedupeChannels(channels: ChannelPlugin[]): ChannelPlugin[] {
const seen = new Set<string>();
const resolved: ChannelPlugin[] = [];
for (const plugin of channels) {
const id = String(plugin.id).trim();
if (!id || seen.has(id)) continue;
seen.add(id);
resolved.push(plugin);
}
return resolved;
}
export function listChannelPlugins(): ChannelPlugin[] { export function listChannelPlugins(): ChannelPlugin[] {
return resolveChannels().sort((a, b) => { const combined = dedupeChannels([...resolveCoreChannels(), ...listPluginChannels()]);
return combined.sort((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId); const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId); const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA); const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);
@@ -42,13 +62,24 @@ export function listChannelPlugins(): ChannelPlugin[] {
} }
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined { export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
return resolveChannels().find((plugin) => plugin.id === id); const resolvedId = String(id).trim();
if (!resolvedId) return undefined;
return listChannelPlugins().find((plugin) => plugin.id === resolvedId);
} }
export function normalizeChannelId(raw?: string | null): ChannelId | null { export function normalizeChannelId(raw?: string | null): ChannelId | null {
// Channel docking: keep input normalization centralized in src/channels/registry.ts // Channel docking: keep input normalization centralized in src/channels/registry.ts
// so CLI/API/protocol can rely on stable aliases without plugin init side effects. // so CLI/API/protocol can rely on stable aliases without plugin init side effects.
return normalizeChatChannelId(raw); const normalized = normalizeChatChannelId(raw);
if (normalized) return normalized;
const trimmed = raw?.trim();
if (!trimmed) return null;
const key = trimmed.toLowerCase();
const plugin = listChannelPlugins().find((entry) => {
if (entry.id.toLowerCase() === key) return true;
return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === key);
});
return plugin?.id ?? null;
} }
export { export {
+10 -2
View File
@@ -1,4 +1,6 @@
import type { ChannelId, ChannelPlugin } from "./types.js"; import type { ChannelId, ChannelPlugin } from "./types.js";
import type { ChatChannelId } from "../registry.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
type PluginLoader = () => Promise<ChannelPlugin>; type PluginLoader = () => Promise<ChannelPlugin>;
@@ -6,7 +8,7 @@ type PluginLoader = () => Promise<ChannelPlugin>;
// //
// This avoids importing `src/channels/plugins/index.ts` (intentionally heavy) // This avoids importing `src/channels/plugins/index.ts` (intentionally heavy)
// from shared flows like outbound delivery / followup routing. // from shared flows like outbound delivery / followup routing.
const LOADERS: Record<ChannelId, PluginLoader> = { const LOADERS: Record<ChatChannelId, PluginLoader> = {
telegram: async () => (await import("./telegram.js")).telegramPlugin, telegram: async () => (await import("./telegram.js")).telegramPlugin,
whatsapp: async () => (await import("./whatsapp.js")).whatsappPlugin, whatsapp: async () => (await import("./whatsapp.js")).whatsappPlugin,
discord: async () => (await import("./discord.js")).discordPlugin, discord: async () => (await import("./discord.js")).discordPlugin,
@@ -21,7 +23,13 @@ const cache = new Map<ChannelId, ChannelPlugin>();
export async function loadChannelPlugin(id: ChannelId): Promise<ChannelPlugin | undefined> { export async function loadChannelPlugin(id: ChannelId): Promise<ChannelPlugin | undefined> {
const cached = cache.get(id); const cached = cache.get(id);
if (cached) return cached; if (cached) return cached;
const loader = LOADERS[id]; const registry = getActivePluginRegistry();
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
if (pluginEntry) {
cache.set(id, pluginEntry.plugin);
return pluginEntry.plugin;
}
const loader = LOADERS[id as ChatChannelId];
if (!loader) return undefined; if (!loader) return undefined;
const plugin = await loader(); const plugin = await loader();
cache.set(id, plugin); cache.set(id, plugin);
+11 -11
View File
@@ -2,23 +2,23 @@ import type { ClawdbotConfig } from "../../config/config.js";
import type { DmPolicy } from "../../config/types.js"; import type { DmPolicy } from "../../config/types.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js"; import type { WizardPrompter } from "../../wizard/prompts.js";
import type { ChatChannelId } from "../registry.js"; import type { ChannelId } from "./types.js";
export type SetupChannelsOptions = { export type SetupChannelsOptions = {
allowDisable?: boolean; allowDisable?: boolean;
allowSignalInstall?: boolean; allowSignalInstall?: boolean;
onSelection?: (selection: ChatChannelId[]) => void; onSelection?: (selection: ChannelId[]) => void;
accountIds?: Partial<Record<ChatChannelId, string>>; accountIds?: Partial<Record<ChannelId, string>>;
onAccountId?: (channel: ChatChannelId, accountId: string) => void; onAccountId?: (channel: ChannelId, accountId: string) => void;
promptAccountIds?: boolean; promptAccountIds?: boolean;
whatsappAccountId?: string; whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean; promptWhatsAppAccountId?: boolean;
onWhatsAppAccountId?: (accountId: string) => void; onWhatsAppAccountId?: (accountId: string) => void;
forceAllowFromChannels?: ChatChannelId[]; forceAllowFromChannels?: ChannelId[];
skipDmPolicyPrompt?: boolean; skipDmPolicyPrompt?: boolean;
skipConfirm?: boolean; skipConfirm?: boolean;
quickstartDefaults?: boolean; quickstartDefaults?: boolean;
initialSelection?: ChatChannelId[]; initialSelection?: ChannelId[];
}; };
export type PromptAccountIdParams = { export type PromptAccountIdParams = {
@@ -33,7 +33,7 @@ export type PromptAccountIdParams = {
export type PromptAccountId = (params: PromptAccountIdParams) => Promise<string>; export type PromptAccountId = (params: PromptAccountIdParams) => Promise<string>;
export type ChannelOnboardingStatus = { export type ChannelOnboardingStatus = {
channel: ChatChannelId; channel: ChannelId;
configured: boolean; configured: boolean;
statusLines: string[]; statusLines: string[];
selectionHint?: string; selectionHint?: string;
@@ -43,7 +43,7 @@ export type ChannelOnboardingStatus = {
export type ChannelOnboardingStatusContext = { export type ChannelOnboardingStatusContext = {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
options?: SetupChannelsOptions; options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChatChannelId, string>>; accountOverrides: Partial<Record<ChannelId, string>>;
}; };
export type ChannelOnboardingConfigureContext = { export type ChannelOnboardingConfigureContext = {
@@ -51,7 +51,7 @@ export type ChannelOnboardingConfigureContext = {
runtime: RuntimeEnv; runtime: RuntimeEnv;
prompter: WizardPrompter; prompter: WizardPrompter;
options?: SetupChannelsOptions; options?: SetupChannelsOptions;
accountOverrides: Partial<Record<ChatChannelId, string>>; accountOverrides: Partial<Record<ChannelId, string>>;
shouldPromptAccountIds: boolean; shouldPromptAccountIds: boolean;
forceAllowFrom: boolean; forceAllowFrom: boolean;
}; };
@@ -63,7 +63,7 @@ export type ChannelOnboardingResult = {
export type ChannelOnboardingDmPolicy = { export type ChannelOnboardingDmPolicy = {
label: string; label: string;
channel: ChatChannelId; channel: ChannelId;
policyKey: string; policyKey: string;
allowFromKey: string; allowFromKey: string;
getCurrent: (cfg: ClawdbotConfig) => DmPolicy; getCurrent: (cfg: ClawdbotConfig) => DmPolicy;
@@ -71,7 +71,7 @@ export type ChannelOnboardingDmPolicy = {
}; };
export type ChannelOnboardingAdapter = { export type ChannelOnboardingAdapter = {
channel: ChatChannelId; channel: ChannelId;
getStatus: (ctx: ChannelOnboardingStatusContext) => Promise<ChannelOnboardingStatus>; getStatus: (ctx: ChannelOnboardingStatusContext) => Promise<ChannelOnboardingStatus>;
configure: (ctx: ChannelOnboardingConfigureContext) => Promise<ChannelOnboardingResult>; configure: (ctx: ChannelOnboardingConfigureContext) => Promise<ChannelOnboardingResult>;
dmPolicy?: ChannelOnboardingDmPolicy; dmPolicy?: ChannelOnboardingDmPolicy;
+14 -5
View File
@@ -1,4 +1,6 @@
import type { ChannelId, ChannelOutboundAdapter } from "../types.js"; import type { ChannelId, ChannelOutboundAdapter } from "../types.js";
import type { ChatChannelId } from "../../registry.js";
import { getActivePluginRegistry } from "../../../plugins/runtime.js";
type OutboundLoader = () => Promise<ChannelOutboundAdapter>; type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
@@ -7,7 +9,7 @@ type OutboundLoader = () => Promise<ChannelOutboundAdapter>;
// The full channel plugins (src/channels/plugins/*.ts) pull in status, // The full channel plugins (src/channels/plugins/*.ts) pull in status,
// onboarding, gateway monitors, etc. Outbound delivery only needs chunking + // onboarding, gateway monitors, etc. Outbound delivery only needs chunking +
// send primitives, so we keep a dedicated, lightweight loader here. // send primitives, so we keep a dedicated, lightweight loader here.
const LOADERS: Record<ChannelId, OutboundLoader> = { const LOADERS: Record<ChatChannelId, OutboundLoader> = {
telegram: async () => (await import("./telegram.js")).telegramOutbound, telegram: async () => (await import("./telegram.js")).telegramOutbound,
whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound, whatsapp: async () => (await import("./whatsapp.js")).whatsappOutbound,
discord: async () => (await import("./discord.js")).discordOutbound, discord: async () => (await import("./discord.js")).discordOutbound,
@@ -24,9 +26,16 @@ export async function loadChannelOutboundAdapter(
): Promise<ChannelOutboundAdapter | undefined> { ): Promise<ChannelOutboundAdapter | undefined> {
const cached = cache.get(id); const cached = cache.get(id);
if (cached) return cached; if (cached) return cached;
const loader = LOADERS[id]; const registry = getActivePluginRegistry();
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
const outbound = pluginEntry?.plugin.outbound;
if (outbound) {
cache.set(id, outbound);
return outbound;
}
const loader = LOADERS[id as ChatChannelId];
if (!loader) return undefined; if (!loader) return undefined;
const outbound = await loader(); const loaded = await loader();
cache.set(id, outbound); cache.set(id, loaded);
return outbound; return loaded;
} }
+5 -1
View File
@@ -7,7 +7,7 @@ import type { GatewayClientMode, GatewayClientName } from "../../utils/message-c
import type { ChatChannelId } from "../registry.js"; import type { ChatChannelId } from "../registry.js";
import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js"; import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js";
export type ChannelId = ChatChannelId; export type ChannelId = ChatChannelId | (string & {});
export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat"; export type ChannelOutboundTargetMode = "explicit" | "implicit" | "heartbeat";
@@ -62,6 +62,10 @@ export type ChannelMeta = {
docsLabel?: string; docsLabel?: string;
blurb: string; blurb: string;
order?: number; order?: number;
aliases?: string[];
selectionDocsPrefix?: string;
selectionDocsOmitLabel?: boolean;
selectionExtras?: string[];
showConfigured?: boolean; showConfigured?: boolean;
quickstartAllowFrom?: boolean; quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean; forceAccountBinding?: boolean;
+4 -14
View File
@@ -1,3 +1,5 @@
import type { ChannelMeta } from "./plugins/types.js";
// Channel docking: add new channels here (order + meta + aliases), then // Channel docking: add new channels here (order + meta + aliases), then
// register the plugin in src/channels/plugins/index.ts and keep protocol IDs in sync. // register the plugin in src/channels/plugins/index.ts and keep protocol IDs in sync.
export const CHAT_CHANNEL_ORDER = [ export const CHAT_CHANNEL_ORDER = [
@@ -16,23 +18,11 @@ export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const;
export const DEFAULT_CHAT_CHANNEL: ChatChannelId = "whatsapp"; export const DEFAULT_CHAT_CHANNEL: ChatChannelId = "whatsapp";
export type ChatChannelMeta = { export type ChatChannelMeta = ChannelMeta;
id: ChatChannelId;
label: string;
selectionLabel: string;
docsPath: string;
docsLabel?: string;
blurb: string;
// Channel docking: selection-line formatting for onboarding prompts.
// Keep this data-driven to avoid channel-specific branches in shared code.
selectionDocsPrefix?: string;
selectionDocsOmitLabel?: boolean;
selectionExtras?: string[];
};
const WEBSITE_URL = "https://clawd.bot"; const WEBSITE_URL = "https://clawd.bot";
const CHAT_CHANNEL_META: Record<ChatChannelId, ChatChannelMeta> = { const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
telegram: { telegram: {
id: "telegram", id: "telegram",
label: "Telegram", label: "Telegram",
+4 -5
View File
@@ -1,5 +1,5 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { listChatChannels } from "../channels/registry.js"; import { listChannelPlugins } from "../channels/plugins/index.js";
import { import {
channelsAddCommand, channelsAddCommand,
channelsListCommand, channelsListCommand,
@@ -36,11 +36,10 @@ const optionNamesAdd = [
const optionNamesRemove = ["channel", "account", "delete"] as const; const optionNamesRemove = ["channel", "account", "delete"] as const;
const channelNames = listChatChannels()
.map((meta) => meta.id)
.join("|");
export function registerChannelsCli(program: Command) { export function registerChannelsCli(program: Command) {
const channelNames = listChannelPlugins()
.map((plugin) => plugin.id)
.join("|");
const channels = program const channels = program
.command("channels") .command("channels")
.description("Manage chat channel accounts") .description("Manage chat channel accounts")
+2 -2
View File
@@ -7,7 +7,7 @@ import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parsePositiveIntOrUndefined } from "../program/helpers.js"; import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import { import {
CRON_CHANNEL_OPTIONS, getCronChannelOptions,
parseAtMs, parseAtMs,
parseDurationMs, parseDurationMs,
printCronList, printCronList,
@@ -81,7 +81,7 @@ export function registerCronAddCommand(cron: Command) {
.option("--model <model>", "Model override for agent jobs (provider/model or alias)") .option("--model <model>", "Model override for agent jobs (provider/model or alias)")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs") .option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false) .option("--deliver", "Deliver agent output", false)
.option("--channel <channel>", `Delivery channel (${CRON_CHANNEL_OPTIONS})`, "last") .option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
.option( .option(
"--to <dest>", "--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
+2 -2
View File
@@ -4,7 +4,7 @@ import { normalizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { import {
CRON_CHANNEL_OPTIONS, getCronChannelOptions,
parseAtMs, parseAtMs,
parseDurationMs, parseDurationMs,
warnIfCronSchedulerDisabled, warnIfCronSchedulerDisabled,
@@ -36,7 +36,7 @@ export function registerCronEditCommand(cron: Command) {
.option("--model <model>", "Model override for agent jobs") .option("--model <model>", "Model override for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs") .option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false) .option("--deliver", "Deliver agent output", false)
.option("--channel <channel>", `Delivery channel (${CRON_CHANNEL_OPTIONS})`) .option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
.option( .option(
"--to <dest>", "--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)", "Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
+3 -2
View File
@@ -1,4 +1,4 @@
import { CHANNEL_IDS } from "../../channels/registry.js"; import { listChannelPlugins } from "../../channels/plugins/index.js";
import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js";
import type { CronJob, CronSchedule } from "../../cron/types.js"; import type { CronJob, CronSchedule } from "../../cron/types.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
@@ -6,7 +6,8 @@ import { colorize, isRich, theme } from "../../terminal/theme.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { callGatewayFromCli } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js";
export const CRON_CHANNEL_OPTIONS = ["last", ...CHANNEL_IDS].join("|"); export const getCronChannelOptions = () =>
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
try { try {
+22
View File
@@ -1,4 +1,8 @@
import { listChannelPlugins } from "../../channels/plugins/index.js"; import { listChannelPlugins } from "../../channels/plugins/index.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { loadConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import { VERSION } from "../../version.js"; import { VERSION } from "../../version.js";
export type ProgramContext = { export type ProgramContext = {
@@ -8,7 +12,25 @@ export type ProgramContext = {
agentChannelOptions: string; agentChannelOptions: string;
}; };
const log = createSubsystemLogger("plugins");
function primePluginRegistry() {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
loadClawdbotPlugins({
config,
workspaceDir,
logger: {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg),
},
});
}
export function createProgramContext(): ProgramContext { export function createProgramContext(): ProgramContext {
primePluginRegistry();
const channelOptions = listChannelPlugins().map((plugin) => plugin.id); const channelOptions = listChannelPlugins().map((plugin) => plugin.id);
return { return {
programVersion: VERSION, programVersion: VERSION,
+4 -5
View File
@@ -1,7 +1,6 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin } from "../channels/plugins/index.js"; import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import type { ChatChannelId } from "../channels/registry.js"; import type { ChannelId } from "../channels/plugins/types.js";
import { normalizeChatChannelId } from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { AgentBinding } from "../config/types.js"; import type { AgentBinding } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js";
@@ -82,7 +81,7 @@ export function applyAgentBindings(
}; };
} }
function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChatChannelId): string { function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChannelId): string {
const plugin = getChannelPlugin(provider); const plugin = getChannelPlugin(provider);
if (!plugin) return DEFAULT_ACCOUNT_ID; if (!plugin) return DEFAULT_ACCOUNT_ID;
return resolveChannelDefaultAccountId({ plugin, cfg }); return resolveChannelDefaultAccountId({ plugin, cfg });
@@ -125,7 +124,7 @@ export function parseBindingSpecs(params: {
const trimmed = raw?.trim(); const trimmed = raw?.trim();
if (!trimmed) continue; if (!trimmed) continue;
const [channelRaw, accountRaw] = trimmed.split(":", 2); const [channelRaw, accountRaw] = trimmed.split(":", 2);
const channel = normalizeChatChannelId(channelRaw); const channel = normalizeChannelId(channelRaw);
if (!channel) { if (!channel) {
errors.push(`Unknown channel "${channelRaw}".`); errors.push(`Unknown channel "${channelRaw}".`);
continue; continue;
+13 -10
View File
@@ -1,13 +1,16 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; import {
import type { ChatChannelId } from "../channels/registry.js"; getChannelPlugin,
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js"; listChannelPlugins,
normalizeChannelId,
} from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { AgentBinding } from "../config/types.js"; import type { AgentBinding } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
type ProviderAccountStatus = { type ProviderAccountStatus = {
provider: ChatChannelId; provider: ChannelId;
accountId: string; accountId: string;
name?: string; name?: string;
state: "linked" | "not linked" | "configured" | "not configured" | "enabled" | "disabled"; state: "linked" | "not linked" | "configured" | "not configured" | "enabled" | "disabled";
@@ -15,16 +18,16 @@ type ProviderAccountStatus = {
configured?: boolean; configured?: boolean;
}; };
function providerAccountKey(provider: ChatChannelId, accountId?: string) { function providerAccountKey(provider: ChannelId, accountId?: string) {
return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`; return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`;
} }
function formatChannelAccountLabel(params: { function formatChannelAccountLabel(params: {
provider: ChatChannelId; provider: ChannelId;
accountId: string; accountId: string;
name?: string; name?: string;
}): string { }): string {
const label = getChatChannelMeta(params.provider).label; const label = getChannelPlugin(params.provider)?.meta.label ?? params.provider;
const account = params.name?.trim() const account = params.name?.trim()
? `${params.accountId} (${params.name.trim()})` ? `${params.accountId} (${params.name.trim()})`
: params.accountId; : params.accountId;
@@ -88,7 +91,7 @@ export async function buildProviderStatusIndex(
return map; return map;
} }
function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChatChannelId): string { function resolveDefaultAccountId(cfg: ClawdbotConfig, provider: ChannelId): string {
const plugin = getChannelPlugin(provider); const plugin = getChannelPlugin(provider);
if (!plugin) return DEFAULT_ACCOUNT_ID; if (!plugin) return DEFAULT_ACCOUNT_ID;
return resolveChannelDefaultAccountId({ plugin, cfg }); return resolveChannelDefaultAccountId({ plugin, cfg });
@@ -117,7 +120,7 @@ export function summarizeBindings(cfg: ClawdbotConfig, bindings: AgentBinding[])
if (bindings.length === 0) return []; if (bindings.length === 0) return [];
const seen = new Map<string, string>(); const seen = new Map<string, string>();
for (const binding of bindings) { for (const binding of bindings) {
const channel = normalizeChatChannelId(binding.match.channel); const channel = normalizeChannelId(binding.match.channel);
if (!channel) continue; if (!channel) continue;
const accountId = binding.match.accountId ?? resolveDefaultAccountId(cfg, channel); const accountId = binding.match.accountId ?? resolveDefaultAccountId(cfg, channel);
const key = providerAccountKey(channel, accountId); const key = providerAccountKey(channel, accountId);
@@ -143,7 +146,7 @@ export function listProvidersForAgent(params: {
if (params.bindings.length > 0) { if (params.bindings.length > 0) {
const seen = new Set<string>(); const seen = new Set<string>();
for (const binding of params.bindings) { for (const binding of params.bindings) {
const channel = normalizeChatChannelId(binding.match.channel); const channel = normalizeChannelId(binding.match.channel);
if (!channel) continue; if (!channel) continue;
const accountId = binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel); const accountId = binding.match.accountId ?? resolveDefaultAccountId(params.cfg, channel);
const key = providerAccountKey(channel, accountId); const key = providerAccountKey(channel, accountId);
+2 -2
View File
@@ -84,7 +84,7 @@ export async function applyAuthChoiceAnthropic(
nextConfig = applyAuthProfileConfig(nextConfig, { nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID, profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic", provider: "anthropic",
mode: "token", mode: "oauth",
}); });
return { config: nextConfig }; return { config: nextConfig };
} }
@@ -146,7 +146,7 @@ export async function applyAuthChoiceAnthropic(
nextConfig = applyAuthProfileConfig(nextConfig, { nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID, profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic", provider: "anthropic",
mode: "token", mode: "oauth",
}); });
return { config: nextConfig }; return { config: nextConfig };
} }
+4 -2
View File
@@ -15,12 +15,14 @@ type LogLine = ReturnType<typeof parseLogLine>;
const DEFAULT_LIMIT = 200; const DEFAULT_LIMIT = 200;
const MAX_BYTES = 1_000_000; const MAX_BYTES = 1_000_000;
const CHANNELS = new Set<string>([...listChannelPlugins().map((plugin) => plugin.id), "all"]);
const getChannelSet = () =>
new Set<string>([...listChannelPlugins().map((plugin) => plugin.id), "all"]);
function parseChannelFilter(raw?: string) { function parseChannelFilter(raw?: string) {
const trimmed = raw?.trim().toLowerCase(); const trimmed = raw?.trim().toLowerCase();
if (!trimmed) return "all"; if (!trimmed) return "all";
return CHANNELS.has(trimmed) ? trimmed : "all"; return getChannelSet().has(trimmed) ? trimmed : "all";
} }
function matchesChannel(line: NonNullable<LogLine>, channel: string) { function matchesChannel(line: NonNullable<LogLine>, channel: string) {
+5 -3
View File
@@ -1,4 +1,4 @@
import { listChatChannels } from "../channels/registry.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -13,7 +13,9 @@ export async function removeChannelConfigWizard(
let next = { ...cfg }; let next = { ...cfg };
const listConfiguredChannels = () => const listConfiguredChannels = () =>
listChatChannels().filter((meta) => next.channels?.[meta.id] !== undefined); listChannelPlugins()
.map((plugin) => plugin.meta)
.filter((meta) => next.channels?.[meta.id] !== undefined);
while (true) { while (true) {
const configured = listConfiguredChannels(); const configured = listConfiguredChannels();
@@ -45,7 +47,7 @@ export async function removeChannelConfigWizard(
if (channel === "done") return next; if (channel === "done") return next;
const label = listChatChannels().find((meta) => meta.id === channel)?.label ?? channel; const label = getChannelPlugin(channel)?.meta.label ?? channel;
const confirmed = guardCancel( const confirmed = guardCancel(
await confirm({ await confirm({
message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`, message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,
+2 -2
View File
@@ -91,12 +91,12 @@ export async function modelsAuthSetupTokenCommand(
applyAuthProfileConfig(cfg, { applyAuthProfileConfig(cfg, {
profileId: CLAUDE_CLI_PROFILE_ID, profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic", provider: "anthropic",
mode: "token", mode: "oauth",
}), }),
); );
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/token)`); runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`);
} }
export async function modelsAuthPasteTokenCommand( export async function modelsAuthPasteTokenCommand(
+12 -12
View File
@@ -1,9 +1,5 @@
import { import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js";
formatChannelPrimerLine, import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js";
formatChannelSelectionLine,
getChatChannelMeta,
listChatChannels,
} from "../channels/registry.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { DmPolicy } from "../config/types.js"; import type { DmPolicy } from "../config/types.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -17,7 +13,7 @@ import {
import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js"; import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js";
async function noteChannelPrimer(prompter: WizardPrompter): Promise<void> { async function noteChannelPrimer(prompter: WizardPrompter): Promise<void> {
const channelLines = listChatChannels().map((meta) => formatChannelPrimerLine(meta)); const channelLines = listChannelPlugins().map((plugin) => formatChannelPrimerLine(plugin.meta));
await prompter.note( await prompter.note(
[ [
"DM security: default is pairing; unknown DMs get a pairing code.", "DM security: default is pairing; unknown DMs get a pairing code.",
@@ -130,11 +126,12 @@ export async function setupChannels(
await noteChannelPrimer(prompter); await noteChannelPrimer(prompter);
const selectionOptions = listChatChannels().map((meta) => { const selectionOptions = listChannelPlugins().map((plugin) => {
const meta = plugin.meta;
const status = statusByChannel.get(meta.id as ChannelChoice); const status = statusByChannel.get(meta.id as ChannelChoice);
return { return {
value: meta.id, value: meta.id,
label: meta.selectionLabel, label: meta.selectionLabel ?? meta.label,
...(status?.selectionHint ? { hint: status.selectionHint } : {}), ...(status?.selectionHint ? { hint: status.selectionHint } : {}),
}; };
}); });
@@ -169,7 +166,10 @@ export async function setupChannels(
options?.onSelection?.(selection); options?.onSelection?.(selection);
const selectionNotes = new Map( const selectionNotes = new Map(
listChatChannels().map((meta) => [meta.id, formatChannelSelectionLine(meta, formatDocsLink)]), listChannelPlugins().map((plugin) => [
plugin.id,
formatChannelSelectionLine(plugin.meta, formatDocsLink),
]),
); );
const selectedLines = selection const selectedLines = selection
.map((channel) => selectionNotes.get(channel)) .map((channel) => selectionNotes.get(channel))
@@ -214,9 +214,9 @@ export async function setupChannels(
if (!status.configured) continue; if (!status.configured) continue;
const adapter = getChannelOnboardingAdapter(channelId); const adapter = getChannelOnboardingAdapter(channelId);
if (!adapter?.disable) continue; if (!adapter?.disable) continue;
const meta = getChatChannelMeta(channelId); const meta = getChannelPlugin(channelId)?.meta;
const disable = await prompter.confirm({ const disable = await prompter.confirm({
message: `Disable ${meta.label} channel?`, message: `Disable ${meta?.label ?? channelId} channel?`,
initialValue: false, initialValue: false,
}); });
if (disable) { if (disable) {
@@ -270,7 +270,7 @@ export async function applyNonInteractiveAuthChoice(params: {
return applyAuthProfileConfig(nextConfig, { return applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID, profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic", provider: "anthropic",
mode: "token", mode: "oauth",
}); });
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ChatChannelId } from "../channels/registry.js"; import type { ChannelId } from "../channels/plugins/types.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type OnboardMode = "local" | "remote"; export type OnboardMode = "local" | "remote";
@@ -31,7 +31,7 @@ export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "auto" | "custom"; export type GatewayBind = "loopback" | "lan" | "auto" | "custom";
export type TailscaleMode = "off" | "serve" | "funnel"; export type TailscaleMode = "off" | "serve" | "funnel";
export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type NodeManagerChoice = "npm" | "pnpm" | "bun";
export type ChannelChoice = ChatChannelId; export type ChannelChoice = ChannelId;
// Legacy alias (pre-rename). // Legacy alias (pre-rename).
export type ProviderChoice = ChannelChoice; export type ProviderChoice = ChannelChoice;
+1 -1
View File
@@ -3,7 +3,7 @@ import {
resolveSandboxConfigForAgent, resolveSandboxConfigForAgent,
resolveSandboxToolPolicyForAgent, resolveSandboxToolPolicyForAgent,
} from "../agents/sandbox.js"; } from "../agents/sandbox.js";
import { normalizeChannelId } from "../channels/registry.js"; import { normalizeChannelId } from "../channels/plugins/index.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { normalizeChannelId } from "../channels/registry.js"; import { normalizeChannelId } from "../channels/plugins/index.js";
import { normalizeAccountId } from "../routing/session-key.js"; import { normalizeAccountId } from "../routing/session-key.js";
import type { ClawdbotConfig } from "./config.js"; import type { ClawdbotConfig } from "./config.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ChannelId } from "../channels/plugins/types.js"; import type { ChannelId } from "../channels/plugins/types.js";
import { normalizeChannelId } from "../channels/registry.js"; import { normalizeChannelId } from "../channels/plugins/index.js";
import type { NativeCommandsSetting } from "./types.js"; import type { NativeCommandsSetting } from "./types.js";
function resolveAutoDefault(providerId?: ChannelId): boolean { function resolveAutoDefault(providerId?: ChannelId): boolean {
@@ -200,6 +200,46 @@ describe("legacy config detection", () => {
} }
}); });
}); });
it("auto-migrates claude-cli auth profile mode to oauth", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
auth: {
profiles: {
"anthropic:claude-cli": { provider: "anthropic", mode: "token" },
},
},
},
null,
2,
),
"utf-8",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules();
try {
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("oauth");
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as {
auth?: { profiles?: Record<string, { mode?: string }> };
};
expect(parsed.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("oauth");
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
it("auto-migrates legacy provider sections on load and writes back", async () => { it("auto-migrates legacy provider sections on load and writes back", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
describe("sandbox docker config", () => {
it("accepts binds array in sandbox.docker config", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
binds: ["/var/run/docker.sock:/var/run/docker.sock", "/home/user/source:/source:rw"],
},
},
},
list: [
{
id: "main",
sandbox: {
docker: {
image: "custom-sandbox:latest",
binds: ["/home/user/projects:/projects:ro"],
},
},
},
],
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.defaults?.sandbox?.docker?.binds).toEqual([
"/var/run/docker.sock:/var/run/docker.sock",
"/home/user/source:/source:rw",
]);
expect(res.config.agents?.list?.[0]?.sandbox?.docker?.binds).toEqual([
"/home/user/projects:/projects:ro",
]);
}
});
it("rejects non-string values in binds array", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
agents: {
defaults: {
sandbox: {
docker: {
binds: [123, "/valid/path:/path"],
},
},
},
},
});
expect(res.ok).toBe(false);
});
});
+14
View File
@@ -10,6 +10,20 @@ import {
} from "./legacy.shared.js"; } from "./legacy.shared.js";
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
id: "auth.anthropic-claude-cli-mode-oauth",
describe: "Switch anthropic:claude-cli auth profile mode to oauth",
apply: (raw, changes) => {
const auth = getRecord(raw.auth);
const profiles = getRecord(auth?.profiles);
if (!profiles) return;
const claudeCli = getRecord(profiles["anthropic:claude-cli"]);
if (!claudeCli) return;
if (claudeCli.mode !== "token") return;
claudeCli.mode = "oauth";
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
},
},
{ {
id: "agent.defaults-v2", id: "agent.defaults-v2",
describe: "Move agent config to agents.defaults and tools", describe: "Move agent config to agents.defaults and tools",
+33 -3
View File
@@ -33,6 +33,12 @@ export type PluginUiMetadata = {
>; >;
}; };
export type ChannelUiMetadata = {
id: string;
label?: string;
description?: string;
};
const GROUP_LABELS: Record<string, string> = { const GROUP_LABELS: Record<string, string> = {
wizard: "Wizard", wizard: "Wizard",
logging: "Logging", logging: "Logging",
@@ -413,6 +419,24 @@ function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): Co
return next; return next;
} }
function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]): ConfigUiHints {
const next: ConfigUiHints = { ...hints };
for (const channel of channels) {
const id = channel.id.trim();
if (!id) continue;
const basePath = `channels.${id}`;
const current = next[basePath] ?? {};
const label = channel.label?.trim();
const help = channel.description?.trim();
next[basePath] = {
...current,
...(label ? { label } : {}),
...(help ? { help } : {}),
};
}
return next;
}
let cachedBase: ConfigSchemaResponse | null = null; let cachedBase: ConfigSchemaResponse | null = null;
function buildBaseConfigSchema(): ConfigSchemaResponse { function buildBaseConfigSchema(): ConfigSchemaResponse {
@@ -433,11 +457,17 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
return next; return next;
} }
export function buildConfigSchema(params?: { plugins?: PluginUiMetadata[] }): ConfigSchemaResponse { export function buildConfigSchema(params?: {
plugins?: PluginUiMetadata[];
channels?: ChannelUiMetadata[];
}): ConfigSchemaResponse {
const base = buildBaseConfigSchema(); const base = buildBaseConfigSchema();
const plugins = params?.plugins ?? []; const plugins = params?.plugins ?? [];
if (plugins.length === 0) return base; const channels = params?.channels ?? [];
const merged = applySensitiveHints(applyPluginHints(base.uiHints, plugins)); if (plugins.length === 0 && channels.length === 0) return base;
const merged = applySensitiveHints(
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
);
return { return {
...base, ...base,
uiHints: merged, uiHints: merged,
+3 -3
View File
@@ -1,8 +1,8 @@
import type { MsgContext } from "../../auto-reply/templating.js"; import type { MsgContext } from "../../auto-reply/templating.js";
import { CHANNEL_IDS } from "../../channels/registry.js"; import { listDeliverableMessageChannels } from "../../utils/message-channel.js";
import type { GroupKeyResolution } from "./types.js"; import type { GroupKeyResolution } from "./types.js";
const GROUP_SURFACES = new Set<string>([...CHANNEL_IDS, "webchat"]); const getGroupSurfaces = () => new Set<string>([...listDeliverableMessageChannels(), "webchat"]);
function normalizeGroupLabel(raw?: string) { function normalizeGroupLabel(raw?: string) {
const trimmed = raw?.trim().toLowerCase() ?? ""; const trimmed = raw?.trim().toLowerCase() ?? "";
@@ -76,7 +76,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
}; };
const parseParts = (parts: string[]) => { const parseParts = (parts: string[]) => {
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) { if (parts.length >= 2 && getGroupSurfaces().has(parts[0])) {
provider = parts[0]; provider = parts[0];
if (parts.length >= 3) { if (parts.length >= 3) {
const kindCandidate = parts[1]; const kindCandidate = parts[1];
+1
View File
@@ -14,4 +14,5 @@ export type ChannelsConfig = {
signal?: SignalConfig; signal?: SignalConfig;
imessage?: IMessageConfig; imessage?: IMessageConfig;
msteams?: MSTeamsConfig; msteams?: MSTeamsConfig;
[key: string]: unknown;
}; };
+1
View File
@@ -77,6 +77,7 @@ export const SandboxDockerSchema = z
apparmorProfile: z.string().optional(), apparmorProfile: z.string().optional(),
dns: z.array(z.string()).optional(), dns: z.array(z.string()).optional(),
extraHosts: z.array(z.string()).optional(), extraHosts: z.array(z.string()).optional(),
binds: z.array(z.string()).optional(),
}) })
.optional(); .optional();
+1
View File
@@ -23,4 +23,5 @@ export const ChannelsSchema = z
imessage: IMessageConfigSchema.optional(), imessage: IMessageConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(),
}) })
.catchall(z.unknown())
.optional(); .optional();
+95 -1
View File
@@ -1,6 +1,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseLaunchctlPrint } from "./launchd.js"; import { installLaunchAgent, parseLaunchctlPrint } from "./launchd.js";
describe("launchd runtime parsing", () => { describe("launchd runtime parsing", () => {
it("parses state, pid, and exit status", () => { it("parses state, pid, and exit status", () => {
@@ -18,3 +23,92 @@ describe("launchd runtime parsing", () => {
}); });
}); });
}); });
describe("launchd install", () => {
it("enables service before bootstrap (clears persisted disabled state)", async () => {
const originalPath = process.env.PATH;
const originalLogPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-launchctl-test-"));
try {
const binDir = path.join(tmpDir, "bin");
const homeDir = path.join(tmpDir, "home");
const logPath = path.join(tmpDir, "launchctl.log");
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(homeDir, { recursive: true });
const stubJsPath = path.join(binDir, "launchctl.js");
await fs.writeFile(
stubJsPath,
[
'import fs from "node:fs";',
"const logPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;",
"if (logPath) {",
" fs.appendFileSync(logPath, JSON.stringify(process.argv.slice(2)) + \"\\n\", \"utf8\");",
"}",
"process.exit(0);",
"",
].join("\n"),
"utf8",
);
if (process.platform === "win32") {
await fs.writeFile(
path.join(binDir, "launchctl.cmd"),
`@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`,
"utf8",
);
} else {
const shPath = path.join(binDir, "launchctl");
await fs.writeFile(
shPath,
`#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`,
"utf8",
);
await fs.chmod(shPath, 0o755);
}
process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = logPath;
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
const env: Record<string, string | undefined> = {
HOME: homeDir,
CLAWDBOT_PROFILE: "default",
};
await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: ["node", "-e", "process.exit(0)"],
});
const calls = (await fs.readFile(logPath, "utf8"))
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as string[]);
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "com.clawdbot.gateway";
const plistPath = path.join(homeDir, "Library", "LaunchAgents", `${label}.plist`);
const serviceId = `${domain}/${label}`;
const enableCalls = calls.filter((c) => c[0] === "enable" && c[1] === serviceId);
expect(enableCalls).toHaveLength(1);
const enableIndex = calls.findIndex((c) => c[0] === "enable" && c[1] === serviceId);
const bootstrapIndex = calls.findIndex(
(c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath,
);
expect(enableIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThanOrEqual(0);
expect(enableIndex).toBeLessThan(bootstrapIndex);
} finally {
process.env.PATH = originalPath;
if (originalLogPath === undefined) {
delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;
} else {
process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = originalLogPath;
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
+2 -1
View File
@@ -410,11 +410,12 @@ export async function installLaunchAgent({
await execLaunchctl(["bootout", domain, plistPath]); await execLaunchctl(["bootout", domain, plistPath]);
await execLaunchctl(["unload", plistPath]); await execLaunchctl(["unload", plistPath]);
// launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap.
await execLaunchctl(["enable", `${domain}/${label}`]);
const boot = await execLaunchctl(["bootstrap", domain, plistPath]); const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
if (boot.code !== 0) { if (boot.code !== 0) {
throw new Error(`launchctl bootstrap failed: ${boot.stderr || boot.stdout}`.trim()); throw new Error(`launchctl bootstrap failed: ${boot.stderr || boot.stdout}`.trim());
} }
await execLaunchctl(["enable", `${domain}/${label}`]);
await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
stdout.write(`${formatLine("Installed LaunchAgent", plistPath)}\n`); stdout.write(`${formatLine("Installed LaunchAgent", plistPath)}\n`);
+5 -5
View File
@@ -136,18 +136,18 @@ export type HookAgentPayload = {
timeoutSeconds?: number; timeoutSeconds?: number;
}; };
const HOOK_CHANNEL_VALUES = ["last", ...listChannelPlugins().map((plugin) => plugin.id)]; const listHookChannelValues = () => ["last", ...listChannelPlugins().map((plugin) => plugin.id)];
export type HookMessageChannel = ChannelId | "last"; export type HookMessageChannel = ChannelId | "last";
const hookChannelSet = new Set<string>(HOOK_CHANNEL_VALUES); const getHookChannelSet = () => new Set<string>(listHookChannelValues());
export const HOOK_CHANNEL_ERROR = `channel must be ${HOOK_CHANNEL_VALUES.join("|")}`; export const getHookChannelError = () => `channel must be ${listHookChannelValues().join("|")}`;
export function resolveHookChannel(raw: unknown): HookMessageChannel | null { export function resolveHookChannel(raw: unknown): HookMessageChannel | null {
if (raw === undefined) return "last"; if (raw === undefined) return "last";
if (typeof raw !== "string") return null; if (typeof raw !== "string") return null;
const normalized = normalizeMessageChannel(raw); const normalized = normalizeMessageChannel(raw);
if (!normalized || !hookChannelSet.has(normalized)) return null; if (!normalized || !getHookChannelSet().has(normalized)) return null;
return normalized as HookMessageChannel; return normalized as HookMessageChannel;
} }
@@ -176,7 +176,7 @@ export function normalizeAgentPayload(
? sessionKeyRaw.trim() ? sessionKeyRaw.trim()
: `hook:${idFactory()}`; : `hook:${idFactory()}`;
const channel = resolveHookChannel(payload.channel); const channel = resolveHookChannel(payload.channel);
if (!channel) return { ok: false, error: HOOK_CHANNEL_ERROR }; if (!channel) return { ok: false, error: getHookChannelError() };
const toRaw = payload.to; const toRaw = payload.to;
const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
const modelRaw = payload.model; const modelRaw = payload.model;
@@ -67,6 +67,11 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async (
description: plugin.description, description: plugin.description,
configUiHints: plugin.configUiHints, configUiHints: plugin.configUiHints,
})), })),
channels: pluginRegistry.channels.map((entry) => ({
id: entry.plugin.id,
label: entry.plugin.meta.label,
description: entry.plugin.meta.blurb,
})),
}); });
return { ok: true, payloadJSON: JSON.stringify(schema) }; return { ok: true, payloadJSON: JSON.stringify(schema) };
} }
+2 -2
View File
@@ -11,7 +11,7 @@ import type { createSubsystemLogger } from "../logging.js";
import { handleControlUiHttpRequest } from "./control-ui.js"; import { handleControlUiHttpRequest } from "./control-ui.js";
import { import {
extractHookToken, extractHookToken,
HOOK_CHANNEL_ERROR, getHookChannelError,
type HookMessageChannel, type HookMessageChannel,
type HooksConfigResolved, type HooksConfigResolved,
normalizeAgentPayload, normalizeAgentPayload,
@@ -152,7 +152,7 @@ export function createHooksRequestHandler(
} }
const channel = resolveHookChannel(mapped.action.channel); const channel = resolveHookChannel(mapped.action.channel);
if (!channel) { if (!channel) {
sendJson(res, 400, { ok: false, error: HOOK_CHANNEL_ERROR }); sendJson(res, 400, { ok: false, error: getHookChannelError() });
return true; return true;
} }
const runId = dispatchAgentHook({ const runId = dispatchAgentHook({
+4 -3
View File
@@ -59,9 +59,10 @@ const BASE_METHODS = [
"chat.send", "chat.send",
]; ];
const CHANNEL_METHODS = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []); export function listGatewayMethods(): string[] {
const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
export const GATEWAY_METHODS = Array.from(new Set([...BASE_METHODS, ...CHANNEL_METHODS])); return Array.from(new Set([...BASE_METHODS, ...channelMethods]));
}
export const GATEWAY_EVENTS = [ export const GATEWAY_EVENTS = [
"agent", "agent",
+5
View File
@@ -73,6 +73,11 @@ export const configHandlers: GatewayRequestHandlers = {
description: plugin.description, description: plugin.description,
configUiHints: plugin.configUiHints, configUiHints: plugin.configUiHints,
})), })),
channels: pluginRegistry.channels.map((entry) => ({
id: entry.plugin.id,
label: entry.plugin.meta.label,
description: entry.plugin.meta.blurb,
})),
}); });
respond(true, schema, undefined); respond(true, schema, undefined);
}, },
+12 -11
View File
@@ -38,7 +38,7 @@ import { buildGatewayCronService } from "./server-cron.js";
import { applyGatewayLaneConcurrency } from "./server-lanes.js"; import { applyGatewayLaneConcurrency } from "./server-lanes.js";
import { startGatewayMaintenanceTimers } from "./server-maintenance.js"; import { startGatewayMaintenanceTimers } from "./server-maintenance.js";
import { coreGatewayHandlers } from "./server-methods.js"; import { coreGatewayHandlers } from "./server-methods.js";
import { GATEWAY_EVENTS, GATEWAY_METHODS } from "./server-methods-list.js"; import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js";
import { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js"; import { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js";
import { loadGatewayPlugins } from "./server-plugins.js"; import { loadGatewayPlugins } from "./server-plugins.js";
@@ -69,14 +69,6 @@ const logReload = log.child("reload");
const logHooks = log.child("hooks"); const logHooks = log.child("hooks");
const logWsControl = log.child("ws"); const logWsControl = log.child("ws");
const canvasRuntime = runtimeForLogger(logCanvas); const canvasRuntime = runtimeForLogger(logCanvas);
const channelLogs = Object.fromEntries(
listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
const channelRuntimeEnvs = Object.fromEntries(
Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]),
) as Record<ChannelId, RuntimeEnv>;
const METHODS = GATEWAY_METHODS;
export type GatewayServer = { export type GatewayServer = {
close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise<void>; close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise<void>;
@@ -163,13 +155,22 @@ export async function startGatewayServer(
await autoMigrateLegacyState({ cfg: cfgAtStart, log }); await autoMigrateLegacyState({ cfg: cfgAtStart, log });
const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
const { pluginRegistry, gatewayMethods } = loadGatewayPlugins({ const baseMethods = listGatewayMethods();
const { pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayPlugins({
cfg: cfgAtStart, cfg: cfgAtStart,
workspaceDir: defaultWorkspaceDir, workspaceDir: defaultWorkspaceDir,
log, log,
coreGatewayHandlers, coreGatewayHandlers,
baseMethods: METHODS, baseMethods,
}); });
const channelLogs = Object.fromEntries(
listChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
const channelRuntimeEnvs = Object.fromEntries(
Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]),
) as Record<ChannelId, RuntimeEnv>;
const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
const gatewayMethods = Array.from(new Set([...baseGatewayMethods, ...channelMethods]));
let pluginServices: PluginServicesHandle | null = null; let pluginServices: PluginServicesHandle | null = null;
const runtimeConfig = await resolveGatewayRuntimeConfig({ const runtimeConfig = await resolveGatewayRuntimeConfig({
cfg: cfgAtStart, cfg: cfgAtStart,
+3 -3
View File
@@ -2,17 +2,17 @@ import { listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { import {
DELIVERABLE_MESSAGE_CHANNELS, listDeliverableMessageChannels,
type DeliverableMessageChannel, type DeliverableMessageChannel,
normalizeMessageChannel, normalizeMessageChannel,
} from "../../utils/message-channel.js"; } from "../../utils/message-channel.js";
export type MessageChannelId = DeliverableMessageChannel; export type MessageChannelId = DeliverableMessageChannel;
const MESSAGE_CHANNELS = [...DELIVERABLE_MESSAGE_CHANNELS]; const getMessageChannels = () => listDeliverableMessageChannels();
function isKnownChannel(value: string): value is MessageChannelId { function isKnownChannel(value: string): value is MessageChannelId {
return (MESSAGE_CHANNELS as readonly string[]).includes(value); return getMessageChannels().includes(value as MessageChannelId);
} }
function isAccountEnabled(account: unknown): boolean { function isAccountEnabled(account: unknown): boolean {
+85
View File
@@ -0,0 +1,85 @@
import crypto from "node:crypto";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
enableConsoleCapture,
resetLogger,
routeLogsToStderr,
setLoggerOverride,
} from "../logging.js";
import { loggingState } from "./state.js";
type ConsoleSnapshot = {
log: typeof console.log;
info: typeof console.info;
warn: typeof console.warn;
error: typeof console.error;
debug: typeof console.debug;
trace: typeof console.trace;
};
let snapshot: ConsoleSnapshot;
beforeEach(() => {
snapshot = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
debug: console.debug,
trace: console.trace,
};
loggingState.consolePatched = false;
loggingState.forceConsoleToStderr = false;
loggingState.rawConsole = null;
resetLogger();
});
afterEach(() => {
console.log = snapshot.log;
console.info = snapshot.info;
console.warn = snapshot.warn;
console.error = snapshot.error;
console.debug = snapshot.debug;
console.trace = snapshot.trace;
loggingState.consolePatched = false;
loggingState.forceConsoleToStderr = false;
loggingState.rawConsole = null;
resetLogger();
setLoggerOverride(null);
vi.restoreAllMocks();
});
describe("enableConsoleCapture", () => {
it("swallows EIO from stderr writes", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
vi.spyOn(process.stderr, "write").mockImplementation(() => {
throw eioError();
});
routeLogsToStderr();
enableConsoleCapture();
expect(() => console.log("hello")).not.toThrow();
});
it("swallows EIO from original console writes", () => {
setLoggerOverride({ level: "info", file: tempLogPath() });
console.log = () => {
throw eioError();
};
enableConsoleCapture();
expect(() => console.log("hello")).not.toThrow();
});
});
function tempLogPath() {
return path.join(os.tmpdir(), `clawdbot-log-${crypto.randomUUID()}.log`);
}
function eioError() {
const err = new Error("EIO") as NodeJS.ErrnoException;
err.code = "EIO";
return err;
}
+2 -1
View File
@@ -89,7 +89,8 @@ function shouldSuppressConsoleMessage(message: string): boolean {
} }
function isEpipeError(err: unknown): boolean { function isEpipeError(err: unknown): boolean {
return Boolean((err as { code?: string })?.code === "EPIPE"); const code = (err as { code?: string })?.code;
return code === "EPIPE" || code === "EIO";
} }
/** /**
+40
View File
@@ -102,4 +102,44 @@ describe("loadClawdbotPlugins", () => {
expect(registry.plugins[0]?.status).toBe("error"); expect(registry.plugins[0]?.status).toBe("error");
expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true); expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true);
}); });
it("registers channel plugins", () => {
const plugin = writePlugin({
id: "channel-demo",
body: `export default function (api) {
api.registerChannel({
plugin: {
id: "demo",
meta: {
id: "demo",
label: "Demo",
selectionLabel: "Demo",
docsPath: "/channels/demo",
blurb: "demo channel"
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" })
},
outbound: { deliveryMode: "direct" }
}
});
};`,
});
const registry = loadClawdbotPlugins({
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["channel-demo"],
},
},
});
expect(registry.channels.length).toBe(1);
expect(registry.channels[0]?.plugin.id).toBe("demo");
});
}); });
+7 -1
View File
@@ -6,6 +6,7 @@ import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { discoverClawdbotPlugins } from "./discovery.js"; import { discoverClawdbotPlugins } from "./discovery.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { setActivePluginRegistry } from "./runtime.js";
import type { import type {
ClawdbotPluginConfigSchema, ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition, ClawdbotPluginDefinition,
@@ -188,6 +189,7 @@ function createPluginRecord(params: {
enabled: params.enabled, enabled: params.enabled,
status: params.enabled ? "loaded" : "disabled", status: params.enabled ? "loaded" : "disabled",
toolNames: [], toolNames: [],
channelIds: [],
gatewayMethods: [], gatewayMethods: [],
cliCommands: [], cliCommands: [],
services: [], services: [],
@@ -211,7 +213,10 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
const cacheEnabled = options.cache !== false; const cacheEnabled = options.cache !== false;
if (cacheEnabled) { if (cacheEnabled) {
const cached = registryCache.get(cacheKey); const cached = registryCache.get(cacheKey);
if (cached) return cached; if (cached) {
setActivePluginRegistry(cached, cacheKey);
return cached;
}
} }
const { registry, createApi } = createPluginRegistry({ const { registry, createApi } = createPluginRegistry({
@@ -359,5 +364,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cacheEnabled) { if (cacheEnabled) {
registryCache.set(cacheKey, registry); registryCache.set(cacheKey, registry);
} }
setActivePluginRegistry(registry, cacheKey);
return registry; return registry;
} }
+43
View File
@@ -1,4 +1,6 @@
import type { AnyAgentTool } from "../agents/tools/common.js"; import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ChannelDock } from "../channels/dock.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { import type {
GatewayRequestHandler, GatewayRequestHandler,
GatewayRequestHandlers, GatewayRequestHandlers,
@@ -6,6 +8,7 @@ import type {
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import type { import type {
ClawdbotPluginApi, ClawdbotPluginApi,
ClawdbotPluginChannelRegistration,
ClawdbotPluginCliRegistrar, ClawdbotPluginCliRegistrar,
ClawdbotPluginService, ClawdbotPluginService,
ClawdbotPluginToolContext, ClawdbotPluginToolContext,
@@ -30,6 +33,13 @@ export type PluginCliRegistration = {
source: string; source: string;
}; };
export type PluginChannelRegistration = {
pluginId: string;
plugin: ChannelPlugin;
dock?: ChannelDock;
source: string;
};
export type PluginServiceRegistration = { export type PluginServiceRegistration = {
pluginId: string; pluginId: string;
service: ClawdbotPluginService; service: ClawdbotPluginService;
@@ -48,6 +58,7 @@ export type PluginRecord = {
status: "loaded" | "disabled" | "error"; status: "loaded" | "disabled" | "error";
error?: string; error?: string;
toolNames: string[]; toolNames: string[];
channelIds: string[];
gatewayMethods: string[]; gatewayMethods: string[];
cliCommands: string[]; cliCommands: string[];
services: string[]; services: string[];
@@ -58,6 +69,7 @@ export type PluginRecord = {
export type PluginRegistry = { export type PluginRegistry = {
plugins: PluginRecord[]; plugins: PluginRecord[];
tools: PluginToolRegistration[]; tools: PluginToolRegistration[];
channels: PluginChannelRegistration[];
gatewayHandlers: GatewayRequestHandlers; gatewayHandlers: GatewayRequestHandlers;
cliRegistrars: PluginCliRegistration[]; cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[]; services: PluginServiceRegistration[];
@@ -73,6 +85,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry: PluginRegistry = { const registry: PluginRegistry = {
plugins: [], plugins: [],
tools: [], tools: [],
channels: [],
gatewayHandlers: {}, gatewayHandlers: {},
cliRegistrars: [], cliRegistrars: [],
services: [], services: [],
@@ -129,6 +142,34 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.gatewayMethods.push(trimmed); record.gatewayMethods.push(trimmed);
}; };
const registerChannel = (
record: PluginRecord,
registration: ClawdbotPluginChannelRegistration | ChannelPlugin,
) => {
const normalized =
typeof (registration as ClawdbotPluginChannelRegistration).plugin === "object"
? (registration as ClawdbotPluginChannelRegistration)
: { plugin: registration as ChannelPlugin };
const plugin = normalized.plugin;
const id = typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim();
if (!id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "channel registration missing id",
});
return;
}
record.channelIds.push(id);
registry.channels.push({
pluginId: record.id,
plugin,
dock: normalized.dock,
source: record.source,
});
};
const registerCli = ( const registerCli = (
record: PluginRecord, record: PluginRecord,
registrar: ClawdbotPluginCliRegistrar, registrar: ClawdbotPluginCliRegistrar,
@@ -179,6 +220,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
pluginConfig: params.pluginConfig, pluginConfig: params.pluginConfig,
logger: normalizeLogger(registryParams.logger), logger: normalizeLogger(registryParams.logger),
registerTool: (tool, opts) => registerTool(record, tool, opts), registerTool: (tool, opts) => registerTool(record, tool, opts),
registerChannel: (registration) => registerChannel(record, registration),
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service), registerService: (service) => registerService(record, service),
@@ -191,6 +233,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
createApi, createApi,
pushDiagnostic, pushDiagnostic,
registerTool, registerTool,
registerChannel,
registerGatewayMethod, registerGatewayMethod,
registerCli, registerCli,
registerService, registerService,
+17
View File
@@ -0,0 +1,17 @@
import type { PluginRegistry } from "./registry.js";
let activeRegistry: PluginRegistry | null = null;
let activeRegistryKey: string | null = null;
export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: string) {
activeRegistry = registry;
activeRegistryKey = cacheKey ?? null;
}
export function getActivePluginRegistry(): PluginRegistry | null {
return activeRegistry;
}
export function getActivePluginRegistryKey(): string | null {
return activeRegistryKey;
}
+8
View File
@@ -1,6 +1,8 @@
import type { Command } from "commander"; import type { Command } from "commander";
import type { AnyAgentTool } from "../agents/tools/common.js"; import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ChannelDock } from "../channels/dock.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
@@ -78,6 +80,11 @@ export type ClawdbotPluginService = {
stop?: (ctx: ClawdbotPluginServiceContext) => void | Promise<void>; stop?: (ctx: ClawdbotPluginServiceContext) => void | Promise<void>;
}; };
export type ClawdbotPluginChannelRegistration = {
plugin: ChannelPlugin;
dock?: ChannelDock;
};
export type ClawdbotPluginDefinition = { export type ClawdbotPluginDefinition = {
id?: string; id?: string;
name?: string; name?: string;
@@ -105,6 +112,7 @@ export type ClawdbotPluginApi = {
tool: AnyAgentTool | ClawdbotPluginToolFactory, tool: AnyAgentTool | ClawdbotPluginToolFactory,
opts?: { name?: string; names?: string[] }, opts?: { name?: string; names?: string[] },
) => void; ) => void;
registerChannel: (registration: ClawdbotPluginChannelRegistration | ChannelPlugin) => void;
registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void; registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void;
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerService: (service: ClawdbotPluginService) => void; registerService: (service: ClawdbotPluginService) => void;
-1
View File
@@ -29,4 +29,3 @@ describe("resolveSlackChannelConfig", () => {
expect(res).toMatchObject({ requireMention: true }); expect(res).toMatchObject({ requireMention: true });
}); });
}); });
+125
View File
@@ -26,9 +26,23 @@ export const registerTelegramHandlers = ({
processMessage, processMessage,
logger, logger,
}) => { }) => {
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
const TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP = 1;
const TELEGRAM_TEXT_FRAGMENT_MAX_PARTS = 12;
const TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS = 50_000;
const mediaGroupBuffer = new Map<string, MediaGroupEntry>(); const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
let mediaGroupProcessing: Promise<void> = Promise.resolve(); let mediaGroupProcessing: Promise<void> = Promise.resolve();
type TextFragmentEntry = {
key: string;
messages: Array<{ msg: TelegramMessage; ctx: unknown; receivedAtMs: number }>;
timer: ReturnType<typeof setTimeout>;
};
const textFragmentBuffer = new Map<string, TextFragmentEntry>();
let textFragmentProcessing: Promise<void> = Promise.resolve();
const processMediaGroup = async (entry: MediaGroupEntry) => { const processMediaGroup = async (entry: MediaGroupEntry) => {
try { try {
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id); entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
@@ -51,6 +65,55 @@ export const registerTelegramHandlers = ({
} }
}; };
const flushTextFragments = async (entry: TextFragmentEntry) => {
try {
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
const first = entry.messages[0];
const last = entry.messages.at(-1);
if (!first || !last) return;
const combinedText = entry.messages.map((m) => m.msg.text ?? "").join("");
if (!combinedText.trim()) return;
const syntheticMessage: TelegramMessage = {
...first.msg,
text: combinedText,
caption: undefined,
caption_entities: undefined,
entities: undefined,
date: last.msg.date ?? first.msg.date,
};
const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
const baseCtx = first.ctx as { me?: unknown; getFile?: unknown } & Record<string, unknown>;
const getFile =
typeof baseCtx.getFile === "function" ? baseCtx.getFile.bind(baseCtx) : async () => ({});
await processMessage(
{ message: syntheticMessage, me: baseCtx.me, getFile },
[],
storeAllowFrom,
{ messageIdOverride: String(last.msg.message_id) },
);
} catch (err) {
runtime.error?.(danger(`text fragment handler failed: ${String(err)}`));
}
};
const scheduleTextFragmentFlush = (entry: TextFragmentEntry) => {
clearTimeout(entry.timer);
entry.timer = setTimeout(async () => {
textFragmentBuffer.delete(entry.key);
textFragmentProcessing = textFragmentProcessing
.then(async () => {
await flushTextFragments(entry);
})
.catch(() => undefined);
await textFragmentProcessing;
}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS);
};
bot.on("callback_query", async (ctx) => { bot.on("callback_query", async (ctx) => {
const callback = ctx.callbackQuery; const callback = ctx.callbackQuery;
if (!callback) return; if (!callback) return;
@@ -226,6 +289,68 @@ export const registerTelegramHandlers = ({
} }
} }
// Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars).
// We buffer “near-limit” messages and append immediately-following parts.
const text = typeof msg.text === "string" ? msg.text : undefined;
const isCommandLike = (text ?? "").trim().startsWith("/");
if (text && !isCommandLike) {
const nowMs = Date.now();
const senderId = msg.from?.id != null ? String(msg.from.id) : "unknown";
const key = `text:${chatId}:${resolvedThreadId ?? "main"}:${senderId}`;
const existing = textFragmentBuffer.get(key);
if (existing) {
const last = existing.messages.at(-1);
const lastMsgId = last?.msg.message_id;
const lastReceivedAtMs = last?.receivedAtMs ?? nowMs;
const idGap = typeof lastMsgId === "number" ? msg.message_id - lastMsgId : Infinity;
const timeGapMs = nowMs - lastReceivedAtMs;
const canAppend =
idGap > 0 &&
idGap <= TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP &&
timeGapMs >= 0 &&
timeGapMs <= TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS;
if (canAppend) {
const currentTotalChars = existing.messages.reduce(
(sum, m) => sum + (m.msg.text?.length ?? 0),
0,
);
const nextTotalChars = currentTotalChars + text.length;
if (
existing.messages.length + 1 <= TELEGRAM_TEXT_FRAGMENT_MAX_PARTS &&
nextTotalChars <= TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS
) {
existing.messages.push({ msg, ctx, receivedAtMs: nowMs });
scheduleTextFragmentFlush(existing);
return;
}
}
// Not appendable (or limits exceeded): flush buffered entry first, then continue normally.
clearTimeout(existing.timer);
textFragmentBuffer.delete(key);
textFragmentProcessing = textFragmentProcessing
.then(async () => {
await flushTextFragments(existing);
})
.catch(() => undefined);
await textFragmentProcessing;
}
const shouldStart = text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS;
if (shouldStart) {
const entry: TextFragmentEntry = {
key,
messages: [{ msg, ctx, receivedAtMs: nowMs }],
timer: setTimeout(() => {}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS),
};
textFragmentBuffer.set(key, entry);
scheduleTextFragmentFlush(entry);
return;
}
}
// Media group handling - buffer multi-image messages // Media group handling - buffer multi-image messages
const mediaGroupId = (msg as { media_group_id?: string }).media_group_id; const mediaGroupId = (msg as { media_group_id?: string }).media_group_id;
if (mediaGroupId) { if (mediaGroupId) {
@@ -409,3 +409,74 @@ describe("telegram media groups", () => {
MEDIA_GROUP_TEST_TIMEOUT_MS, MEDIA_GROUP_TEST_TIMEOUT_MS,
); );
}); });
describe("telegram text fragments", () => {
beforeEach(() => {
// These tests rely on real setTimeout aggregation; guard against leaked fake timers.
vi.useRealTimers();
});
const TEXT_FRAGMENT_POLL_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 15_000;
const TEXT_FRAGMENT_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000;
const waitForFragmentProcessing = async (
replySpy: ReturnType<typeof vi.fn>,
expectedCalls: number,
) => {
await expect
.poll(() => replySpy.mock.calls.length, { timeout: TEXT_FRAGMENT_POLL_TIMEOUT_MS })
.toBe(expectedCalls);
};
it(
"buffers near-limit text and processes sequential parts as one message",
async () => {
const { createTelegramBot } = await import("./bot.js");
const replyModule = await import("../auto-reply/reply.js");
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
onSpy.mockReset();
replySpy.mockReset();
createTelegramBot({ token: "tok" });
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(handler).toBeDefined();
const part1 = "A".repeat(4050);
const part2 = "B".repeat(50);
await handler({
message: {
chat: { id: 42, type: "private" },
message_id: 10,
date: 1736380800,
text: part1,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({}),
});
await handler({
message: {
chat: { id: 42, type: "private" },
message_id: 11,
date: 1736380801,
text: part2,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({}),
});
expect(replySpy).not.toHaveBeenCalled();
await waitForFragmentProcessing(replySpy, 1);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0] as { RawBody?: string; Body?: string };
expect(payload.RawBody).toContain(part1.slice(0, 32));
expect(payload.RawBody).toContain(part2.slice(0, 32));
},
TEXT_FRAGMENT_TEST_TIMEOUT_MS,
);
});
+38 -12
View File
@@ -3,6 +3,7 @@ import {
listChatChannelAliases, listChatChannelAliases,
normalizeChatChannelId, normalizeChatChannelId,
} from "../channels/registry.js"; } from "../channels/registry.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { import {
GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES, GATEWAY_CLIENT_NAMES,
@@ -11,6 +12,7 @@ import {
normalizeGatewayClientMode, normalizeGatewayClientMode,
normalizeGatewayClientName, normalizeGatewayClientName,
} from "../gateway/protocol/client-info.js"; } from "../gateway/protocol/client-info.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const; export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const;
export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL; export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL;
@@ -42,34 +44,58 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined
const normalized = raw?.trim().toLowerCase(); const normalized = raw?.trim().toLowerCase();
if (!normalized) return undefined; if (!normalized) return undefined;
if (normalized === INTERNAL_MESSAGE_CHANNEL) return INTERNAL_MESSAGE_CHANNEL; if (normalized === INTERNAL_MESSAGE_CHANNEL) return INTERNAL_MESSAGE_CHANNEL;
return normalizeChatChannelId(normalized) ?? normalized; const builtIn = normalizeChatChannelId(normalized);
if (builtIn) return builtIn;
const registry = getActivePluginRegistry();
const pluginMatch = registry?.channels.find((entry) => {
if (entry.plugin.id.toLowerCase() === normalized) return true;
return (entry.plugin.meta.aliases ?? []).some(
(alias) => alias.trim().toLowerCase() === normalized,
);
});
return pluginMatch?.plugin.id ?? normalized;
} }
export const DELIVERABLE_MESSAGE_CHANNELS = CHANNEL_IDS; const listPluginChannelIds = (): string[] => {
const registry = getActivePluginRegistry();
if (!registry) return [];
return registry.channels.map((entry) => entry.plugin.id);
};
export type DeliverableMessageChannel = (typeof DELIVERABLE_MESSAGE_CHANNELS)[number]; const listPluginChannelAliases = (): string[] => {
const registry = getActivePluginRegistry();
if (!registry) return [];
return registry.channels.flatMap((entry) => entry.plugin.meta.aliases ?? []);
};
export const listDeliverableMessageChannels = (): ChannelId[] =>
Array.from(new Set([...CHANNEL_IDS, ...listPluginChannelIds()]));
export type DeliverableMessageChannel = ChannelId;
export type GatewayMessageChannel = DeliverableMessageChannel | InternalMessageChannel; export type GatewayMessageChannel = DeliverableMessageChannel | InternalMessageChannel;
export const GATEWAY_MESSAGE_CHANNELS = [ export const listGatewayMessageChannels = (): GatewayMessageChannel[] => [
...DELIVERABLE_MESSAGE_CHANNELS, ...listDeliverableMessageChannels(),
INTERNAL_MESSAGE_CHANNEL, INTERNAL_MESSAGE_CHANNEL,
] as const; ];
export const GATEWAY_AGENT_CHANNEL_ALIASES = listChatChannelAliases(); export const listGatewayAgentChannelAliases = (): string[] =>
Array.from(new Set([...listChatChannelAliases(), ...listPluginChannelAliases()]));
export type GatewayAgentChannelHint = GatewayMessageChannel | "last"; export type GatewayAgentChannelHint = GatewayMessageChannel | "last";
export const GATEWAY_AGENT_CHANNEL_VALUES = Array.from( export const listGatewayAgentChannelValues = (): string[] =>
new Set([...GATEWAY_MESSAGE_CHANNELS, "last", ...GATEWAY_AGENT_CHANNEL_ALIASES]), Array.from(
); new Set([...listGatewayMessageChannels(), "last", ...listGatewayAgentChannelAliases()]),
);
export function isGatewayMessageChannel(value: string): value is GatewayMessageChannel { export function isGatewayMessageChannel(value: string): value is GatewayMessageChannel {
return (GATEWAY_MESSAGE_CHANNELS as readonly string[]).includes(value); return listGatewayMessageChannels().includes(value as GatewayMessageChannel);
} }
export function isDeliverableMessageChannel(value: string): value is DeliverableMessageChannel { export function isDeliverableMessageChannel(value: string): value is DeliverableMessageChannel {
return (DELIVERABLE_MESSAGE_CHANNELS as readonly string[]).includes(value); return listDeliverableMessageChannels().includes(value as DeliverableMessageChannel);
} }
export function resolveGatewayMessageChannel( export function resolveGatewayMessageChannel(