Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddfa51ba31 | ||
|
|
467d32ae45 | ||
|
|
e54b9ac743 |
No files matched your search
+1
-26
@@ -2,42 +2,17 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.22 (unreleased)
|
||||
|
||||
### Changes
|
||||
- TUI: run local shell commands with `!` after per-session consent, and warn when local exec stays disabled. (#1463) Thanks @vignesh07.
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
- macOS: add attach-only debug toggle + `--attach-only`/`--no-launchd` flag to skip launchd installs.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
## 2026.1.22
|
||||
|
||||
### Fixes
|
||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||
- Agents: avoid sanitizing tool call IDs for OpenAI responses to preserve Pi pairing.
|
||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||
|
||||
## 2026.1.21-2
|
||||
|
||||
### Fixes
|
||||
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
||||
- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447)
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ struct DebugSettings: View {
|
||||
@State private var modelsError: String?
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let healthStore = HealthStore.shared
|
||||
@State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
||||
@State private var launchAgentWriteError: String?
|
||||
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||
@State private var sessionStoreSaveError: String?
|
||||
@@ -49,7 +47,6 @@ struct DebugSettings: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
|
||||
self.launchdSection
|
||||
self.appInfoSection
|
||||
self.gatewaySection
|
||||
self.logsSection
|
||||
@@ -82,39 +79,6 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var launchdSection: some View {
|
||||
GroupBox("Gateway startup") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled)
|
||||
.onChange(of: self.launchAgentWriteDisabled) { _, newValue in
|
||||
self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue)
|
||||
if self.launchAgentWriteError != nil {
|
||||
self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
||||
return
|
||||
}
|
||||
if newValue {
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
enabled: false,
|
||||
bundlePath: Bundle.main.bundlePath,
|
||||
port: GatewayEnvironment.gatewayPort())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). It will only attach to an existing Gateway.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let launchAgentWriteError {
|
||||
Text(launchAgentWriteError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
|
||||
@@ -4,46 +4,11 @@ enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||
|
||||
private static var disableLaunchAgentMarkerURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
}
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
static func isLaunchAgentWriteDisabled() -> Bool {
|
||||
FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path)
|
||||
}
|
||||
|
||||
static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
|
||||
let marker = self.disableLaunchAgentMarkerURL
|
||||
if disabled {
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: marker.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if !FileManager().fileExists(atPath: marker.path) {
|
||||
FileManager().createFile(atPath: marker.path, contents: nil)
|
||||
}
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if FileManager().fileExists(atPath: marker.path) {
|
||||
do {
|
||||
try FileManager().removeItem(at: marker)
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isLoaded() async -> Bool {
|
||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||
return loaded
|
||||
@@ -101,6 +66,12 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
extension GatewayLaunchAgentManager {
|
||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
||||
let marker = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
return FileManager().fileExists(atPath: marker.path)
|
||||
}
|
||||
|
||||
private static func readDaemonLoaded() async -> Bool? {
|
||||
let result = await self.runDaemonCommandResult(
|
||||
["status", "--json", "--no-probe"],
|
||||
|
||||
@@ -79,11 +79,6 @@ final class GatewayProcessManager {
|
||||
|
||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
|
||||
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
|
||||
return
|
||||
}
|
||||
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||
guard !enabled else { return }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
@@ -242,12 +237,13 @@ final class GatewayProcessManager {
|
||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||
let linkId = order.first(where: { snap.channels[$0]?.linked == true })
|
||||
?? order.first(where: { snap.channels[$0]?.linked != nil })
|
||||
guard let linkId else {
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
let linkId = snap.channelOrder?.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.channels.keys.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
@@ -312,15 +308,6 @@ final class GatewayProcessManager {
|
||||
return
|
||||
}
|
||||
|
||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||
let message = "Launchd disabled; start the Gateway manually or disable attach-only."
|
||||
self.status = .failed(message)
|
||||
self.lastFailureReason = "launchd disabled"
|
||||
self.appendLog("[gateway] launchd disabled; skipping auto-start\n")
|
||||
self.logger.info("gateway launchd enable skipped (disable marker set)")
|
||||
return
|
||||
}
|
||||
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||
|
||||
@@ -166,11 +166,6 @@ final class HealthStore {
|
||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||
{
|
||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||
for id in order {
|
||||
if let summary = snap.channels[id], summary.linked == true {
|
||||
return (id: id, summary: summary)
|
||||
}
|
||||
}
|
||||
for id in order {
|
||||
if let summary = snap.channels[id], summary.linked != nil {
|
||||
return (id: id, summary: summary)
|
||||
|
||||
@@ -3,7 +3,6 @@ import Darwin
|
||||
import Foundation
|
||||
import MenuBarExtraAccess
|
||||
import Observation
|
||||
import OSLog
|
||||
import Security
|
||||
import SwiftUI
|
||||
|
||||
@@ -11,7 +10,6 @@ import SwiftUI
|
||||
struct ClawdbotApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@State private var state: AppState
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "app")
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
@@ -33,7 +31,6 @@ struct ClawdbotApp: App {
|
||||
|
||||
init() {
|
||||
ClawdbotLogging.bootstrapIfNeeded()
|
||||
Self.applyAttachOnlyOverrideIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
}
|
||||
|
||||
@@ -94,22 +91,6 @@ struct ClawdbotApp: App {
|
||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||
}
|
||||
|
||||
private static func applyAttachOnlyOverrideIfNeeded() {
|
||||
let args = CommandLine.arguments
|
||||
guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
|
||||
if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
|
||||
Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
enabled: false,
|
||||
bundlePath: Bundle.main.bundlePath,
|
||||
port: GatewayEnvironment.gatewayPort())
|
||||
}
|
||||
Self.logger.info("attach-only flag enabled")
|
||||
}
|
||||
|
||||
private var isGatewaySleeping: Bool {
|
||||
if self.state.isPaused { return false }
|
||||
switch self.state.connectionMode {
|
||||
|
||||
@@ -121,7 +121,7 @@ Resolution priority:
|
||||
|
||||
### Delivery (channel + target)
|
||||
Isolated jobs can deliver output to a channel. The job payload can specify:
|
||||
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`
|
||||
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
|
||||
- `to`: channel-specific recipient target
|
||||
|
||||
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
|
||||
@@ -133,7 +133,7 @@ Delivery notes:
|
||||
- Use `deliver: false` to keep output internal even if a `to` is present.
|
||||
|
||||
Target format reminders:
|
||||
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||
- Slack/Discord targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||
- Telegram topics should use the `:topic:` form (see below).
|
||||
|
||||
#### Telegram delivery targets (topics / forum threads)
|
||||
|
||||
@@ -71,8 +71,8 @@ Payload:
|
||||
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
|
||||
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
||||
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
||||
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
||||
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
||||
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
||||
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
||||
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
||||
|
||||
@@ -15,7 +15,6 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
---
|
||||
summary: "Mattermost bot setup and Clawdbot config"
|
||||
read_when:
|
||||
- Setting up Mattermost
|
||||
- Debugging Mattermost routing
|
||||
---
|
||||
|
||||
# Mattermost (plugin)
|
||||
|
||||
Status: supported via plugin (bot token + WebSocket events). Channels, groups, and DMs are supported.
|
||||
|
||||
## Plugin required
|
||||
Mattermost ships as a plugin and is not bundled with the core install.
|
||||
|
||||
Install via CLI (npm registry):
|
||||
```bash
|
||||
clawdbot plugins install @clawdbot/mattermost
|
||||
```
|
||||
|
||||
Local checkout (when running from a git repo):
|
||||
```bash
|
||||
clawdbot plugins install ./extensions/mattermost
|
||||
```
|
||||
|
||||
If you choose Mattermost during configure/onboarding and a git checkout is detected,
|
||||
Clawdbot will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
|
||||
## Quick setup
|
||||
1) Install the Mattermost plugin.
|
||||
2) Create a Mattermost bot account and copy the **bot token**.
|
||||
3) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
|
||||
4) Configure Clawdbot and start the gateway.
|
||||
|
||||
Minimal config:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "mm-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables (default account)
|
||||
Set these on the gateway host if you prefer env vars:
|
||||
|
||||
- `MATTERMOST_BOT_TOKEN=...`
|
||||
- `MATTERMOST_URL=https://chat.example.com`
|
||||
|
||||
Env vars apply only to the **default** account (`default`). Other accounts must use config values.
|
||||
|
||||
## Chat modes
|
||||
Mattermost responds to DMs automatically. Channel behavior is controlled by `chatmode`:
|
||||
|
||||
- `oncall` (default): respond only when @mentioned in channels.
|
||||
- `onmessage`: respond to every channel message.
|
||||
- `onchar`: respond when a message starts with a trigger prefix.
|
||||
|
||||
Config example:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
chatmode: "onchar",
|
||||
oncharPrefixes: [">", "!"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `onchar` still responds to explicit @mentions.
|
||||
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
||||
|
||||
## Access control (DMs)
|
||||
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).
|
||||
- Approve via:
|
||||
- `clawdbot pairing list mattermost`
|
||||
- `clawdbot pairing approve mattermost <CODE>`
|
||||
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
|
||||
|
||||
## Channels (groups)
|
||||
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
|
||||
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`).
|
||||
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
|
||||
|
||||
## Targets for outbound delivery
|
||||
Use these target formats with `clawdbot message send` or cron/webhooks:
|
||||
|
||||
- `channel:<id>` for a channel
|
||||
- `user:<id>` for a DM
|
||||
- `@username` for a DM (resolved via the Mattermost API)
|
||||
|
||||
Bare IDs are treated as channels.
|
||||
|
||||
## Multi-account
|
||||
Mattermost supports multiple accounts under `channels.mattermost.accounts`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
accounts: {
|
||||
default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" },
|
||||
alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
|
||||
- Auth errors: check the bot token, base URL, and whether the account is enabled.
|
||||
- Multi-account issues: env vars only apply to the `default` account.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot channels` (accounts, status, login/logout, logs)"
|
||||
read_when:
|
||||
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage)
|
||||
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage)
|
||||
- You want to check channel status or tail channel logs
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ clawdbot gateway probe --ssh user@gateway-host
|
||||
Options:
|
||||
- `--ssh <target>`: `user@host` or `user@host:port` (port defaults to `22`).
|
||||
- `--ssh-identity <path>`: identity file.
|
||||
- `--ssh-auto`: pick the first discovered gateway host as SSH target (LAN/WAB only).
|
||||
- `--ssh-auto`: pick the first discovered bridge host as SSH target (LAN/WAB only).
|
||||
|
||||
Config (optional, used as defaults):
|
||||
- `gateway.remote.sshTarget`
|
||||
|
||||
+6
-5
@@ -293,7 +293,7 @@ Options:
|
||||
- `--reset` (reset config + credentials + sessions + workspace before wizard)
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||
- `--flow <quickstart|advanced>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
@@ -352,7 +352,7 @@ Options:
|
||||
## Channel helpers
|
||||
|
||||
### `channels`
|
||||
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
|
||||
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams).
|
||||
|
||||
Subcommands:
|
||||
- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included).
|
||||
@@ -365,7 +365,7 @@ Subcommands:
|
||||
- `channels logout`: log out of a channel session (if supported).
|
||||
|
||||
Common options:
|
||||
- `--channel <name>`: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams`
|
||||
- `--channel <name>`: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
|
||||
- `--account <id>`: channel account id (default `default`)
|
||||
- `--name <label>`: display name for the account
|
||||
|
||||
@@ -472,7 +472,7 @@ Options:
|
||||
- `--session-id <id>`
|
||||
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
|
||||
- `--verbose <on|full|off>`
|
||||
- `--channel <whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams>`
|
||||
- `--channel <whatsapp|telegram|discord|slack|signal|imessage>`
|
||||
- `--local`
|
||||
- `--deliver`
|
||||
- `--json`
|
||||
@@ -791,10 +791,11 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
||||
[`clawdbot node`](/cli/node).
|
||||
|
||||
Subcommands:
|
||||
- `node run --host <gateway-host> --port 18789`
|
||||
- `node run --host <gateway-host> --port 18790`
|
||||
- `node status`
|
||||
- `node install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||
- `node uninstall`
|
||||
- `node run`
|
||||
- `node stop`
|
||||
- `node restart`
|
||||
|
||||
|
||||
+3
-4
@@ -8,7 +8,7 @@ read_when:
|
||||
# `clawdbot message`
|
||||
|
||||
Single outbound command for sending messages and channel actions
|
||||
(Discord/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
||||
(Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -19,14 +19,13 @@ clawdbot message <subcommand> [flags]
|
||||
Channel selection:
|
||||
- `--channel` required if more than one channel is configured.
|
||||
- If exactly one channel is configured, it becomes the default.
|
||||
- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin)
|
||||
- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
|
||||
|
||||
Target formats (`--target`):
|
||||
- WhatsApp: E.164 or group JID
|
||||
- Telegram: chat id or `@username`
|
||||
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
|
||||
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
||||
- Mattermost (plugin): `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
|
||||
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
||||
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
|
||||
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
||||
@@ -50,7 +49,7 @@ Name lookup:
|
||||
### Core
|
||||
|
||||
- `send`
|
||||
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams
|
||||
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
||||
- Required: `--target`, plus `--message` or `--media`
|
||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
||||
|
||||
+13
-17
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# `clawdbot node`
|
||||
|
||||
Run a **headless node host** that connects to the Gateway WebSocket and exposes
|
||||
Run a **headless node host** that connects to the Gateway bridge and exposes
|
||||
`system.run` / `system.which` on this machine.
|
||||
|
||||
## Why use a node host?
|
||||
@@ -26,14 +26,14 @@ node host, so you can keep command access scoped and explicit.
|
||||
## Run (foreground)
|
||||
|
||||
```bash
|
||||
clawdbot node run --host <gateway-host> --port 18789
|
||||
clawdbot node run --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--host <host>`: Gateway WebSocket host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway WebSocket port (default: `18789`)
|
||||
- `--tls`: Use TLS for the gateway connection
|
||||
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
|
||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||
- `--tls`: Use TLS for the bridge connection
|
||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||
- `--node-id <id>`: Override node id (clears pairing token)
|
||||
- `--display-name <name>`: Override the node display name
|
||||
|
||||
@@ -42,14 +42,14 @@ Options:
|
||||
Install a headless node host as a user service.
|
||||
|
||||
```bash
|
||||
clawdbot node install --host <gateway-host> --port 18789
|
||||
clawdbot node install --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--host <host>`: Gateway WebSocket host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway WebSocket port (default: `18789`)
|
||||
- `--tls`: Use TLS for the gateway connection
|
||||
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
|
||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||
- `--tls`: Use TLS for the bridge connection
|
||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||
- `--node-id <id>`: Override node id (clears pairing token)
|
||||
- `--display-name <name>`: Override the node display name
|
||||
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
|
||||
@@ -59,15 +59,12 @@ Manage the service:
|
||||
|
||||
```bash
|
||||
clawdbot node status
|
||||
clawdbot node run
|
||||
clawdbot node stop
|
||||
clawdbot node restart
|
||||
clawdbot node uninstall
|
||||
```
|
||||
|
||||
Use `clawdbot node run` for a foreground node host (no service).
|
||||
|
||||
Service commands accept `--json` for machine-readable output.
|
||||
|
||||
## Pairing
|
||||
|
||||
The first connection creates a pending node pair request on the Gateway.
|
||||
@@ -78,8 +75,7 @@ clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
The node host stores its node id, token, display name, and gateway connection info in
|
||||
`~/.clawdbot/node.json`.
|
||||
The node host stores its node id + token in `~/.clawdbot/node.json`.
|
||||
|
||||
## Exec approvals
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@ Related:
|
||||
- Camera: [Camera nodes](/nodes/camera)
|
||||
- Images: [Image nodes](/nodes/images)
|
||||
|
||||
Common options:
|
||||
- `--url`, `--token`, `--timeout`, `--json`
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
@@ -43,11 +40,6 @@ clawdbot nodes run --raw "git status"
|
||||
clawdbot nodes run --agent main --node <id|name|ip> --raw "git status"
|
||||
```
|
||||
|
||||
Invoke flags:
|
||||
- `--params <json>`: JSON object string (default `{}`).
|
||||
- `--invoke-timeout <ms>`: node invoke timeout (default `15000`).
|
||||
- `--idempotency-key <key>`: optional idempotency key.
|
||||
|
||||
### Exec-style defaults
|
||||
|
||||
`nodes run` mirrors the model’s exec behavior (defaults + approvals):
|
||||
@@ -55,14 +47,8 @@ Invoke flags:
|
||||
- Reads `tools.exec.*` (plus `agents.list[].tools.exec.*` overrides).
|
||||
- Uses exec approvals (`exec.approval.request`) before invoking `system.run`.
|
||||
- `--node` can be omitted when `tools.exec.node` is set.
|
||||
- Requires a node that advertises `system.run` (macOS companion app or headless node host).
|
||||
|
||||
Flags:
|
||||
- `--cwd <path>`: working directory.
|
||||
- `--env <key=val>`: env override (repeatable).
|
||||
- `--command-timeout <ms>`: command timeout.
|
||||
- `--invoke-timeout <ms>`: node invoke timeout (default `30000`).
|
||||
- `--needs-screen-recording`: require screen recording permission.
|
||||
- `--raw <command>`: run a shell string (`/bin/sh -lc` or `cmd.exe /c`).
|
||||
- `--agent <id>`: agent-scoped approvals/allowlists (defaults to configured agent).
|
||||
- `--ask <off|on-miss|always>`, `--security <deny|allowlist|full>`: overrides.
|
||||
@@ -16,10 +16,6 @@ Related:
|
||||
```bash
|
||||
clawdbot onboard
|
||||
clawdbot onboard --flow quickstart
|
||||
clawdbot onboard --flow manual
|
||||
clawdbot onboard --mode remote --remote-url ws://gateway-host:18789
|
||||
```
|
||||
|
||||
Flow notes:
|
||||
- `quickstart`: minimal prompts, auto-generates a gateway token.
|
||||
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
|
||||
@@ -5,7 +5,7 @@ read_when:
|
||||
---
|
||||
# Gateway architecture
|
||||
|
||||
Last updated: 2026-01-22
|
||||
Last updated: 2026-01-19
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -34,8 +34,7 @@ Last updated: 2026-01-22
|
||||
|
||||
### Nodes (macOS / iOS / Android / headless)
|
||||
- Connect to the **same WS server** with `role: node`.
|
||||
- Provide a device identity in `connect`; pairing is **device‑based** (role `node`) and
|
||||
approval lives in the device pairing store.
|
||||
- Pair with the Gateway to receive a token.
|
||||
- Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`.
|
||||
|
||||
Protocol details:
|
||||
|
||||
@@ -52,9 +52,10 @@ Instances list, `client.mode === "cli"` is **not** turned into a presence entry.
|
||||
Clients can send richer periodic beacons via the `system-event` method. The mac
|
||||
app uses this to report host name, IP, and `lastInputSeconds`.
|
||||
|
||||
### 4) Node connects (role: node)
|
||||
When a node connects over the Gateway WebSocket with `role: node`, the Gateway
|
||||
upserts a presence entry for that node (same flow as other WS clients).
|
||||
### 4) Node bridge beacons
|
||||
|
||||
When a node bridge connection authenticates, the Gateway emits a presence entry
|
||||
for that node and refreshes it periodically so it doesn’t expire.
|
||||
|
||||
## Merge + dedupe rules (why `instanceId` matters)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Goal: small, hard-to-misuse tool set so agents can list sessions, fetch history,
|
||||
- Group chats use `agent:<agentId>:<channel>:group:<id>` or `agent:<agentId>:<channel>:channel:<id>` (pass the full key).
|
||||
- Cron jobs use `cron:<job.id>`.
|
||||
- Hooks use `hook:<uuid>` unless explicitly set.
|
||||
- Node sessions use `node-<nodeId>` unless explicitly set.
|
||||
- Node bridge uses `node-<nodeId>` unless explicitly set.
|
||||
|
||||
`global` and `unknown` are reserved values and are never listed. If `session.scope = "global"`, we alias it to `main` for all tools so callers never see `global`.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Other sources:
|
||||
- Cron jobs: `cron:<job.id>`
|
||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
||||
- Node runs: `node-<nodeId>`
|
||||
- Node bridge runs: `node-<nodeId>`
|
||||
|
||||
## Lifecycle
|
||||
- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
|
||||
|
||||
@@ -46,7 +46,7 @@ Common methods + events:
|
||||
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
|
||||
| Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these |
|
||||
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
|
||||
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | Gateway WS + node actions |
|
||||
| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
|
||||
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
|
||||
|
||||
Authoritative list lives in `src/gateway/server.ts` (`METHODS`, `EVENTS`).
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ What this does:
|
||||
- `CLAWDBOT_PROFILE=dev`
|
||||
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
|
||||
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
|
||||
- `CLAWDBOT_GATEWAY_PORT=19001` (browser/canvas shift accordingly)
|
||||
- `CLAWDBOT_GATEWAY_PORT=19001` (bridge/canvas/browser shift accordingly)
|
||||
|
||||
2) **Dev bootstrap** (`gateway --dev`)
|
||||
- Writes a minimal config if missing (`gateway.mode=local`, bind loopback).
|
||||
|
||||
@@ -133,14 +133,6 @@
|
||||
"source": "/message/",
|
||||
"destination": "/cli/message"
|
||||
},
|
||||
{
|
||||
"source": "/mattermost",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/mattermost/",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/providers/discord",
|
||||
"destination": "/channels/discord"
|
||||
@@ -173,14 +165,6 @@
|
||||
"source": "/providers/location/",
|
||||
"destination": "/channels/location"
|
||||
},
|
||||
{
|
||||
"source": "/providers/mattermost",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/providers/mattermost/",
|
||||
"destination": "/channels/mattermost"
|
||||
},
|
||||
{
|
||||
"source": "/providers/msteams",
|
||||
"destination": "/channels/msteams"
|
||||
@@ -946,7 +930,6 @@
|
||||
"channels/grammy",
|
||||
"channels/discord",
|
||||
"channels/slack",
|
||||
"channels/mattermost",
|
||||
"channels/signal",
|
||||
"channels/imessage",
|
||||
"channels/msteams",
|
||||
|
||||
+15
-14
@@ -7,7 +7,7 @@ read_when:
|
||||
# Bonjour / mDNS discovery
|
||||
|
||||
Clawdbot uses Bonjour (mDNS / DNS‑SD) as a **LAN‑only convenience** to discover
|
||||
an active Gateway (WebSocket endpoint). It is best‑effort and does **not** replace SSH or
|
||||
an active Gateway bridge. It is best‑effort and does **not** replace SSH or
|
||||
Tailnet-based connectivity.
|
||||
|
||||
## Wide‑area Bonjour (Unicast DNS‑SD) over Tailscale
|
||||
@@ -31,7 +31,7 @@ browse both `local.` and `clawdbot.internal.` automatically.
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: { bind: "tailnet" }, // tailnet-only (recommended)
|
||||
bridge: { bind: "tailnet" }, // tailnet-only (recommended)
|
||||
discovery: { wideArea: { enabled: true } } // enables clawdbot.internal DNS-SD publishing
|
||||
}
|
||||
```
|
||||
@@ -63,13 +63,13 @@ In the Tailscale admin console:
|
||||
Once clients accept tailnet DNS, iOS nodes can browse
|
||||
`_clawdbot-gw._tcp` in `clawdbot.internal.` without multicast.
|
||||
|
||||
### Gateway listener security (recommended)
|
||||
### Bridge listener security (recommended)
|
||||
|
||||
The Gateway WS port (default `18789`) binds to loopback by default. For LAN/tailnet
|
||||
access, bind explicitly and keep auth enabled.
|
||||
The bridge port (default `18790`) is a plain TCP service. By default it binds to
|
||||
`0.0.0.0`, which makes it reachable from any interface on the gateway host.
|
||||
|
||||
For tailnet‑only setups:
|
||||
- Set `gateway.bind: "tailnet"` in `~/.clawdbot/clawdbot.json`.
|
||||
- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json`.
|
||||
- Restart the Gateway (or restart the macOS menubar app).
|
||||
|
||||
## What advertises
|
||||
@@ -87,12 +87,11 @@ The Gateway advertises small non‑secret hints to make UI flows convenient:
|
||||
- `role=gateway`
|
||||
- `displayName=<friendly name>`
|
||||
- `lanHost=<hostname>.local`
|
||||
- `gatewayPort=<port>` (Gateway WS + HTTP)
|
||||
- `gatewayTls=1` (only when TLS is enabled)
|
||||
- `gatewayTlsSha256=<sha256>` (only when TLS is enabled and fingerprint is available)
|
||||
- `gatewayPort=<port>` (informational; Gateway WS is usually loopback‑only)
|
||||
- `bridgePort=<port>` (only when bridge is enabled)
|
||||
- `canvasPort=<port>` (only when the canvas host is enabled; default `18793`)
|
||||
- `sshPort=<port>` (defaults to 22 when not overridden)
|
||||
- `transport=gateway`
|
||||
- `transport=bridge`
|
||||
- `cliPath=<path>` (optional; absolute path to a runnable `clawdbot` entrypoint)
|
||||
- `tailnetDns=<magicdns>` (optional hint when Tailnet is available)
|
||||
|
||||
@@ -126,8 +125,8 @@ The Gateway writes a rolling log file (printed on startup as
|
||||
The iOS node uses `NWBrowser` to discover `_clawdbot-gw._tcp`.
|
||||
|
||||
To capture logs:
|
||||
- Settings → Gateway → Advanced → **Discovery Debug Logs**
|
||||
- Settings → Gateway → Advanced → **Discovery Logs** → reproduce → **Copy**
|
||||
- Settings → Bridge → Advanced → **Discovery Debug Logs**
|
||||
- Settings → Bridge → Advanced → **Discovery Logs** → reproduce → **Copy**
|
||||
|
||||
The log includes browser state transitions and result‑set changes.
|
||||
|
||||
@@ -137,7 +136,7 @@ The log includes browser state transitions and result‑set changes.
|
||||
- **Multicast blocked**: some Wi‑Fi networks disable mDNS.
|
||||
- **Sleep / interface churn**: macOS may temporarily drop mDNS results; retry.
|
||||
- **Browse works but resolve fails**: keep machine names simple (avoid emojis or
|
||||
punctuation), then restart the Gateway. The service instance name derives from
|
||||
punctuation), then restart the Gateway. The bridge instance name derives from
|
||||
the host name, so overly complex names can confuse some resolvers.
|
||||
|
||||
## Escaped instance names (`\032`)
|
||||
@@ -151,7 +150,9 @@ sequences (e.g. spaces become `\032`).
|
||||
## Disabling / configuration
|
||||
|
||||
- `CLAWDBOT_DISABLE_BONJOUR=1` disables advertising.
|
||||
- `gateway.bind` in `~/.clawdbot/clawdbot.json` controls the Gateway bind mode.
|
||||
- `CLAWDBOT_BRIDGE_ENABLED=0` disables the bridge listener (and the bridge beacon).
|
||||
- `bridge.bind` / `bridge.port` in `~/.clawdbot/clawdbot.json` control bridge bind/port.
|
||||
- `CLAWDBOT_BRIDGE_HOST` / `CLAWDBOT_BRIDGE_PORT` still work as back‑compat overrides.
|
||||
- `CLAWDBOT_SSH_PORT` overrides the SSH port advertised in TXT.
|
||||
- `CLAWDBOT_TAILNET_DNS` publishes a MagicDNS hint in TXT.
|
||||
- `CLAWDBOT_CLI_PATH` overrides the advertised CLI path.
|
||||
|
||||
@@ -14,9 +14,6 @@ should use the unified Gateway WebSocket protocol instead.
|
||||
If you are building an operator or node client, use the
|
||||
[Gateway protocol](/gateway/protocol).
|
||||
|
||||
**Note:** Current Clawdbot builds no longer ship the TCP bridge listener; this document is kept for historical reference.
|
||||
Legacy `bridge.*` config keys are no longer part of the config schema.
|
||||
|
||||
## Why we have both
|
||||
|
||||
- **Security boundary**: the bridge exposes a small allowlist instead of the
|
||||
@@ -31,7 +28,7 @@ Legacy `bridge.*` config keys are no longer part of the config schema.
|
||||
|
||||
- TCP, one JSON object per line (JSONL).
|
||||
- Optional TLS (when `bridge.tls.enabled` is true).
|
||||
- Legacy default listener port was `18790` (current builds do not start a TCP bridge).
|
||||
- Gateway owns the listener (default `18790`).
|
||||
|
||||
When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
||||
`bridgeTlsSha256` so nodes can pin the certificate.
|
||||
@@ -57,7 +54,7 @@ Gateway → Client:
|
||||
- `event`: chat updates for subscribed sessions
|
||||
- `ping` / `pong`: keepalive
|
||||
|
||||
Legacy allowlist enforcement lived in `src/gateway/server-bridge.ts` (removed).
|
||||
Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
||||
|
||||
## Exec lifecycle events
|
||||
|
||||
|
||||
@@ -568,5 +568,5 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
|
||||
- If you set `dmPolicy: "open"`, the matching `allowFrom` list must include `"*"`.
|
||||
- Provider IDs differ (phone numbers, user IDs, channel IDs). Use the provider docs to confirm the format.
|
||||
- Optional sections to add later: `web`, `browser`, `ui`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`.
|
||||
- Optional sections to add later: `web`, `browser`, `ui`, `bridge`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`.
|
||||
- See [Providers](/channels/whatsapp) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes.
|
||||
@@ -543,7 +543,7 @@ Notes:
|
||||
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
|
||||
- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`.
|
||||
|
||||
### `channels.telegram.accounts` / `channels.discord.accounts` / `channels.slack.accounts` / `channels.mattermost.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
|
||||
### `channels.telegram.accounts` / `channels.discord.accounts` / `channels.slack.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
|
||||
|
||||
Run multiple accounts per channel (each account has its own `accountId` and optional `name`):
|
||||
|
||||
@@ -1204,44 +1204,6 @@ Slack action groups (gate `slack` tool actions):
|
||||
| memberInfo | enabled | Member info |
|
||||
| emojiList | enabled | Custom emoji list |
|
||||
|
||||
### `channels.mattermost` (bot token)
|
||||
|
||||
Mattermost ships as a plugin and is not bundled with the core install.
|
||||
Install it first: `clawdbot plugins install @clawdbot/mattermost` (or `./extensions/mattermost` from a git checkout).
|
||||
|
||||
Mattermost requires a bot token plus the base URL for your server:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "mm-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
dmPolicy: "pairing",
|
||||
chatmode: "oncall", // oncall | onmessage | onchar
|
||||
oncharPrefixes: [">", "!"],
|
||||
textChunkLimit: 4000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clawdbot starts Mattermost when the account is configured (bot token + base URL) and enabled. The token + base URL are resolved from `channels.mattermost.botToken` + `channels.mattermost.baseUrl` or `MATTERMOST_BOT_TOKEN` + `MATTERMOST_URL` for the default account (unless `channels.mattermost.enabled` is `false`).
|
||||
|
||||
Chat modes:
|
||||
- `oncall` (default): respond to channel messages only when @mentioned.
|
||||
- `onmessage`: respond to every channel message.
|
||||
- `onchar`: respond when a message starts with a trigger prefix (`channels.mattermost.oncharPrefixes`, default `[">", "!"]`).
|
||||
|
||||
Access control:
|
||||
- Default DMs: `channels.mattermost.dmPolicy="pairing"` (unknown senders get a pairing code).
|
||||
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
|
||||
- Groups: `channels.mattermost.groupPolicy="allowlist"` by default (mention-gated). Use `channels.mattermost.groupAllowFrom` to restrict senders.
|
||||
|
||||
Multi-account support lives under `channels.mattermost.accounts` (see the multi-account section above). Env vars only apply to the default account.
|
||||
Use `channel:<id>` or `user:<id>` (or `@username`) when specifying delivery targets; bare ids are treated as channel ids.
|
||||
|
||||
### `channels.signal` (signal-cli)
|
||||
|
||||
Signal reactions can emit system events (shared reaction tooling):
|
||||
@@ -1728,7 +1690,7 @@ auto-compaction, instructing the model to store durable memories on disk (e.g.
|
||||
`memory/YYYY-MM-DD.md`). It triggers when the session token estimate crosses a
|
||||
soft threshold below the compaction limit.
|
||||
|
||||
Legacy defaults:
|
||||
Defaults:
|
||||
- `memoryFlush.enabled`: `true`
|
||||
- `memoryFlush.softThresholdTokens`: `4000`
|
||||
- `memoryFlush.prompt` / `memoryFlush.systemPrompt`: built-in defaults with `NO_REPLY`
|
||||
@@ -1773,9 +1735,8 @@ Block streaming:
|
||||
with `maxChars` capped to the channel text limit. Signal/Slack/Discord default
|
||||
to `minChars: 1500` unless overridden.
|
||||
Channel overrides: `channels.whatsapp.blockStreamingCoalesce`, `channels.telegram.blockStreamingCoalesce`,
|
||||
`channels.discord.blockStreamingCoalesce`, `channels.slack.blockStreamingCoalesce`, `channels.mattermost.blockStreamingCoalesce`,
|
||||
`channels.signal.blockStreamingCoalesce`, `channels.imessage.blockStreamingCoalesce`, `channels.msteams.blockStreamingCoalesce`
|
||||
(and per-account variants).
|
||||
`channels.discord.blockStreamingCoalesce`, `channels.slack.blockStreamingCoalesce`, `channels.signal.blockStreamingCoalesce`,
|
||||
`channels.imessage.blockStreamingCoalesce`, `channels.msteams.blockStreamingCoalesce` (and per-account variants).
|
||||
- `agents.defaults.humanDelay`: randomized pause between **block replies** after the first.
|
||||
Modes: `off` (default), `natural` (800–2500ms), `custom` (use `minMs`/`maxMs`).
|
||||
Per-agent override: `agents.list[].humanDelay`.
|
||||
@@ -2823,7 +2784,7 @@ Hot-applied (no full gateway restart):
|
||||
|
||||
Requires full Gateway restart:
|
||||
- `gateway` (port/bind/auth/control UI/tailscale)
|
||||
- `bridge` (legacy)
|
||||
- `bridge`
|
||||
- `discovery`
|
||||
- `canvasHost`
|
||||
- `plugins`
|
||||
@@ -2841,7 +2802,7 @@ Convenience flags (CLI):
|
||||
- `clawdbot --dev …` → uses `~/.clawdbot-dev` + shifts ports from base `19001`
|
||||
- `clawdbot --profile <name> …` → uses `~/.clawdbot-<name>` (port via config/env/flags)
|
||||
|
||||
See [Gateway runbook](/gateway) for the derived port mapping (gateway/browser/canvas).
|
||||
See [Gateway runbook](/gateway) for the derived port mapping (gateway/bridge/browser/canvas).
|
||||
See [Multiple gateways](/gateway/multiple-gateways) for browser/CDP port isolation details.
|
||||
|
||||
Example:
|
||||
@@ -2960,7 +2921,7 @@ The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can
|
||||
|
||||
Default root: `~/clawd/canvas`
|
||||
Default port: `18793` (chosen to avoid the clawd browser CDP port `18792`)
|
||||
The server listens on the **gateway bind host** (LAN or Tailnet) so nodes can reach it.
|
||||
The server listens on the **bridge bind host** (LAN or Tailnet) so nodes can reach it.
|
||||
|
||||
The server:
|
||||
- serves files under `canvasHost.root`
|
||||
@@ -2989,13 +2950,9 @@ Disable with:
|
||||
- config: `canvasHost: { enabled: false }`
|
||||
- env: `CLAWDBOT_SKIP_CANVAS_HOST=1`
|
||||
|
||||
### `bridge` (legacy TCP bridge, removed)
|
||||
### `bridge` (node bridge server)
|
||||
|
||||
Current builds no longer include the TCP bridge listener; `bridge.*` config keys are ignored.
|
||||
Nodes connect over the Gateway WebSocket. This section is kept for historical reference.
|
||||
|
||||
Legacy behavior:
|
||||
- The Gateway could expose a simple TCP bridge for nodes (iOS/Android), typically on port `18790`.
|
||||
The Gateway can expose a simple TCP bridge for nodes (iOS/Android), typically on port `18790`.
|
||||
|
||||
Defaults:
|
||||
- enabled: `true`
|
||||
|
||||
+17
-18
@@ -3,7 +3,7 @@ summary: "Node discovery and transports (Bonjour, Tailscale, SSH) for finding th
|
||||
read_when:
|
||||
- Implementing or changing Bonjour discovery/advertising
|
||||
- Adjusting remote connection modes (direct vs SSH)
|
||||
- Designing node discovery + pairing for remote nodes
|
||||
- Designing bridge + pairing for remote nodes
|
||||
---
|
||||
# Discovery & transports
|
||||
|
||||
@@ -17,18 +17,17 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
|
||||
## Terms
|
||||
|
||||
- **Gateway**: a single long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. Most setups use one per host; isolated multi-gateway setups are possible.
|
||||
- **Gateway WS (control plane)**: the WebSocket endpoint on `127.0.0.1:18789` by default; can be bound to LAN/tailnet via `gateway.bind`.
|
||||
- **Direct WS transport**: a LAN/tailnet-facing Gateway WS endpoint (no SSH).
|
||||
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
|
||||
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
|
||||
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
|
||||
- **Legacy TCP bridge (deprecated/removed)**: older node transport (see [Bridge protocol](/gateway/bridge-protocol)); no longer advertised for discovery.
|
||||
|
||||
Protocol details:
|
||||
- [Gateway protocol](/gateway/protocol)
|
||||
- [Bridge protocol (legacy)](/gateway/bridge-protocol)
|
||||
- [Bridge protocol](/gateway/bridge-protocol)
|
||||
|
||||
## Why we keep both “direct” and SSH
|
||||
|
||||
- **Direct WS** is the best UX on the same network and within a tailnet:
|
||||
- **Direct bridge** is the best UX on the same network and within a tailnet:
|
||||
- auto-discovery on LAN via Bonjour
|
||||
- pairing tokens + ACLs owned by the gateway
|
||||
- no shell access required; protocol surface can stay tight and auditable
|
||||
@@ -44,7 +43,7 @@ Protocol details:
|
||||
Bonjour is best-effort and does not cross networks. It is only used for “same LAN” convenience.
|
||||
|
||||
Target direction:
|
||||
- The **gateway** advertises its WS endpoint via Bonjour.
|
||||
- The **gateway** advertises its bridge via Bonjour.
|
||||
- Clients browse and show a “pick a gateway” list, then store the chosen endpoint.
|
||||
|
||||
Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
|
||||
@@ -57,19 +56,19 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
|
||||
- `role=gateway`
|
||||
- `lanHost=<hostname>.local`
|
||||
- `sshPort=22` (or whatever is advertised)
|
||||
- `gatewayPort=18789` (Gateway WS + HTTP)
|
||||
- `gatewayTls=1` (only when TLS is enabled)
|
||||
- `gatewayTlsSha256=<sha256>` (only when TLS is enabled and fingerprint is available)
|
||||
- `gatewayPort=18789` (loopback WS port; informational)
|
||||
- `bridgePort=18790` (when bridge is enabled)
|
||||
- `canvasPort=18793` (default canvas host port; serves `/__clawdbot__/canvas/`)
|
||||
- `cliPath=<path>` (optional; absolute path to a runnable `clawdbot` entrypoint or binary)
|
||||
- `tailnetDns=<magicdns>` (optional hint; auto-detected when Tailscale is available)
|
||||
|
||||
Disable/override:
|
||||
- `CLAWDBOT_DISABLE_BONJOUR=1` disables advertising.
|
||||
- `gateway.bind` in `~/.clawdbot/clawdbot.json` controls the Gateway bind mode.
|
||||
- `CLAWDBOT_SSH_PORT` overrides the SSH port advertised in TXT (defaults to 22).
|
||||
- `CLAWDBOT_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS).
|
||||
- `CLAWDBOT_CLI_PATH` overrides the advertised CLI path.
|
||||
- `CLAWDBOT_BRIDGE_ENABLED=0` disables the bridge listener.
|
||||
- `bridge.bind` / `bridge.port` in `~/.clawdbot/clawdbot.json` control bridge bind/port (preferred).
|
||||
- `CLAWDBOT_BRIDGE_HOST` / `CLAWDBOT_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set.
|
||||
- `CLAWDBOT_SSH_PORT` overrides the SSH port advertised in the bridge beacon (defaults to 22).
|
||||
- `CLAWDBOT_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the bridge beacon (auto-detected if unset).
|
||||
|
||||
### 2) Tailnet (cross-network)
|
||||
|
||||
@@ -98,13 +97,13 @@ Recommended client behavior:
|
||||
The gateway is the source of truth for node/client admission.
|
||||
|
||||
- Pairing requests are created/approved/rejected in the gateway (see [Gateway pairing](/gateway/pairing)).
|
||||
- The gateway enforces:
|
||||
- The bridge enforces:
|
||||
- auth (token / keypair)
|
||||
- scopes/ACLs (the gateway is not a raw proxy to every method)
|
||||
- scopes/ACLs (bridge is not a raw proxy to every gateway method)
|
||||
- rate limits
|
||||
|
||||
## Responsibilities by component
|
||||
|
||||
- **Gateway**: advertises discovery beacons, owns pairing decisions, and hosts the WS endpoint.
|
||||
- **Gateway**: advertises discovery beacons, owns pairing decisions, runs the bridge listener.
|
||||
- **macOS app**: helps you pick a gateway, shows pairing prompts, and uses SSH only as a fallback.
|
||||
- **iOS/Android nodes**: browse Bonjour as a convenience and connect to the paired Gateway WS.
|
||||
- **iOS/Android nodes**: browse Bonjour as a convenience and connect via the paired bridge.
|
||||
@@ -82,12 +82,14 @@ Defaults (can be overridden via env/flags/config):
|
||||
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
|
||||
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
|
||||
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
|
||||
- `bridge.port=19002` (derived: `gateway.port+1`)
|
||||
- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
|
||||
- `canvasHost.port=19005` (derived: `gateway.port+4`)
|
||||
- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
|
||||
|
||||
Derived ports (rules of thumb):
|
||||
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
|
||||
- `bridge.port = base + 1` (or `CLAWDBOT_BRIDGE_PORT` / config override)
|
||||
- `browser.controlUrl port = base + 2` (or `CLAWDBOT_BROWSER_CONTROL_URL` / config override)
|
||||
- `canvasHost.port = base + 4` (or `CLAWDBOT_CANVAS_HOST_PORT` / config override)
|
||||
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
|
||||
@@ -112,7 +114,7 @@ CLAWDBOT_CONFIG_PATH=~/.clawdbot/b.json CLAWDBOT_STATE_DIR=~/.clawdbot-b clawdbo
|
||||
```
|
||||
|
||||
## Protocol (operator view)
|
||||
- Full docs: [Gateway protocol](/gateway/protocol) and [Bridge protocol (legacy)](/gateway/bridge-protocol).
|
||||
- Full docs: [Gateway protocol](/gateway/protocol) and [Bridge protocol](/gateway/bridge-protocol).
|
||||
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{id,displayName?,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId?}, caps, auth?, locale?, userAgent? } }`.
|
||||
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
|
||||
- After handshake:
|
||||
@@ -128,7 +130,7 @@ CLAWDBOT_CONFIG_PATH=~/.clawdbot/b.json CLAWDBOT_STATE_DIR=~/.clawdbot-b clawdbo
|
||||
- `system-event` — post a presence/system note (structured).
|
||||
- `send` — send a message via the active channel(s).
|
||||
- `agent` — run an agent turn (streams events back on same connection).
|
||||
- `node.list` — list paired + currently-connected nodes (includes `caps`, `deviceFamily`, `modelIdentifier`, `paired`, `connected`, and advertised `commands`).
|
||||
- `node.list` — list paired + currently-connected bridge nodes (includes `caps`, `deviceFamily`, `modelIdentifier`, `paired`, `connected`, and advertised `commands`).
|
||||
- `node.describe` — describe a node (capabilities + supported `node.invoke` commands; works for paired nodes and for currently-connected unpaired nodes).
|
||||
- `node.invoke` — invoke a command on a node (e.g. `canvas.*`, `camera.*`).
|
||||
- `node.pair.*` — pairing lifecycle (`request`, `list`, `approve`, `reject`, `verify`).
|
||||
|
||||
@@ -13,7 +13,7 @@ Most setups should use one Gateway because a single Gateway can handle multiple
|
||||
- `CLAWDBOT_STATE_DIR` — per-instance sessions, creds, caches
|
||||
- `agents.defaults.workspace` — per-instance workspace root
|
||||
- `gateway.port` (or `--port`) — unique per instance
|
||||
- Derived ports (browser/canvas) must not overlap
|
||||
- Derived ports (bridge/browser/canvas) must not overlap
|
||||
|
||||
If these are shared, you will hit config races and port conflicts.
|
||||
|
||||
@@ -47,7 +47,7 @@ Run a second Gateway on the same host with its own:
|
||||
|
||||
This keeps the rescue bot isolated from the main bot so it can debug or apply config changes if the primary bot is down.
|
||||
|
||||
Port spacing: leave at least 20 ports between base ports so the derived browser/canvas/CDP ports never collide.
|
||||
Port spacing: leave at least 20 ports between base ports so the derived bridge/browser/canvas/CDP ports never collide.
|
||||
|
||||
### How to install (rescue bot)
|
||||
|
||||
@@ -73,6 +73,7 @@ clawdbot --profile rescue gateway install
|
||||
|
||||
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
|
||||
|
||||
- `bridge.port = base + 1`
|
||||
- `browser.controlUrl port = base + 2`
|
||||
- `canvasHost.port = base + 4`
|
||||
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
|
||||
|
||||
+7
-10
@@ -11,20 +11,16 @@ In Gateway-owned pairing, the **Gateway** is the source of truth for which nodes
|
||||
are allowed to join. UIs (macOS app, future clients) are just frontends that
|
||||
approve or reject pending requests.
|
||||
|
||||
**Important:** WS nodes use **device pairing** (role `node`) during `connect`.
|
||||
`node.pair.*` is a separate pairing store and does **not** gate the WS handshake.
|
||||
Only clients that explicitly call `node.pair.*` use this flow.
|
||||
|
||||
## Concepts
|
||||
|
||||
- **Pending request**: a node asked to join; requires approval.
|
||||
- **Paired node**: approved node with an issued auth token.
|
||||
- **Transport**: the Gateway WS endpoint forwards requests but does not decide
|
||||
membership. (Legacy TCP bridge support is deprecated/removed.)
|
||||
- **Bridge**: transport endpoint only; it forwards requests but does not decide
|
||||
membership.
|
||||
|
||||
## How pairing works
|
||||
|
||||
1. A node connects to the Gateway WS and requests pairing.
|
||||
1. A node connects to the bridge and requests pairing.
|
||||
2. The Gateway stores a **pending request** and emits `node.pair.requested`.
|
||||
3. You approve or reject the request (CLI or UI).
|
||||
4. On approval, the Gateway issues a **new token** (tokens are rotated on re‑pair).
|
||||
@@ -85,8 +81,9 @@ Security notes:
|
||||
- Tokens are secrets; treat `paired.json` as sensitive.
|
||||
- Rotating a token requires re-approval (or deleting the node entry).
|
||||
|
||||
## Transport behavior
|
||||
## Bridge behavior
|
||||
|
||||
- The transport is **stateless**; it does not store membership.
|
||||
- The bridge is **transport only**; it does not store membership.
|
||||
- If the Gateway is offline or pairing is disabled, nodes cannot pair.
|
||||
- If the Gateway is in remote mode, pairing still happens against the remote Gateway’s store.
|
||||
- If the bridge is running but the Gateway is in remote mode, pairing still
|
||||
happens against the remote Gateway’s store.
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
This repo supports “remote over SSH” by keeping a single Gateway (the master) running on a dedicated host (desktop/server) and connecting clients to it.
|
||||
|
||||
- For **operators (you / the macOS app)**: SSH tunneling is the universal fallback.
|
||||
- For **nodes (iOS/Android and future devices)**: connect to the Gateway **WebSocket** (LAN/tailnet or SSH tunnel as needed).
|
||||
- For **nodes (iOS/Android and future devices)**: prefer the Gateway **Bridge** when on the same LAN/tailnet (see [Discovery](/gateway/discovery)).
|
||||
|
||||
## The core idea
|
||||
|
||||
@@ -55,12 +55,12 @@ One gateway service owns state + channels. Nodes are peripherals.
|
||||
Flow example (Telegram → node):
|
||||
- Telegram message arrives at the **Gateway**.
|
||||
- Gateway runs the **agent** and decides whether to call a node tool.
|
||||
- Gateway calls the **node** over the Gateway WebSocket (`node.*` RPC).
|
||||
- Gateway calls the **node** over the Bridge (`node.*` RPC).
|
||||
- Node returns the result; Gateway replies back out to Telegram.
|
||||
|
||||
Notes:
|
||||
- **Nodes do not run the gateway service.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
|
||||
- macOS app “node mode” is just a node client over the Gateway WebSocket.
|
||||
- macOS app “node mode” is just a node client over the Bridge.
|
||||
|
||||
## SSH tunnel (CLI + tools)
|
||||
|
||||
|
||||
@@ -94,8 +94,8 @@ clawdbot gateway --tailscale funnel --auth password
|
||||
or `tailscale funnel` configuration on shutdown.
|
||||
- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel).
|
||||
- `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only.
|
||||
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
|
||||
the same Gateway WS endpoint, so Serve can work for node access.
|
||||
- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
|
||||
uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
|
||||
|
||||
## Browser control server (remote Gateway + local browser)
|
||||
|
||||
|
||||
+6
-9
@@ -13,7 +13,6 @@ read_when:
|
||||
|
||||
<p align="center">
|
||||
<strong>Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).</strong><br />
|
||||
Plugins add Mattermost and more.
|
||||
Send a message, get an agent response — from your pocket.
|
||||
</p>
|
||||
|
||||
@@ -24,7 +23,7 @@ read_when:
|
||||
<a href="/start/clawd">Clawdbot assistant setup</a>
|
||||
</p>
|
||||
|
||||
Clawdbot bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono). Plugins add Mattermost (Bot API + WebSocket) and more.
|
||||
Clawdbot bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono).
|
||||
Clawdbot also powers [Clawd](https://clawd.me), the space‑lobster assistant.
|
||||
|
||||
## Start here
|
||||
@@ -45,12 +44,12 @@ Remote access: [Web surfaces](/web) and [Tailscale](/gateway/tailscale)
|
||||
## How it works
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Discord / iMessage (+ plugins)
|
||||
WhatsApp / Telegram / Discord
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789 (loopback-only)
|
||||
│ (single source) │
|
||||
│ (single source) │ tcp://0.0.0.0:18790 (Bridge)
|
||||
│ │ http://<gateway-host>:18793
|
||||
│ │ /__clawdbot__/canvas/ (Canvas host)
|
||||
└───────────┬───────────────┘
|
||||
@@ -59,8 +58,8 @@ WhatsApp / Telegram / Discord / iMessage (+ plugins)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ Chat UI (SwiftUI)
|
||||
├─ macOS app (Clawdbot.app)
|
||||
├─ iOS node via Gateway WS + pairing
|
||||
└─ Android node via Gateway WS + pairing
|
||||
├─ iOS node via Bridge + pairing
|
||||
└─ Android node via Bridge + pairing
|
||||
```
|
||||
|
||||
Most operations flow through the **Gateway** (`clawdbot gateway`), a single long-running process that owns channel connections and the WebSocket control plane.
|
||||
@@ -71,7 +70,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
|
||||
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
|
||||
- The wizard now generates a gateway token by default (even for loopback).
|
||||
- For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).
|
||||
- **Nodes**: connect to the Gateway WebSocket (LAN/tailnet/SSH as needed); legacy TCP bridge is deprecated/removed.
|
||||
- **Bridge for nodes**: optional LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable).
|
||||
- **Canvas host**: HTTP file server on `canvasHost.port` (default `18793`), serving `/__clawdbot__/canvas/` for node WebViews; see [Gateway configuration](/gateway/configuration) (`canvasHost`).
|
||||
- **Remote use**: SSH tunnel or tailnet/VPN; see [Remote access](/gateway/remote) and [Discovery](/gateway/discovery).
|
||||
|
||||
@@ -80,7 +79,6 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
|
||||
- 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol
|
||||
- ✈️ **Telegram Bot** — DMs + groups via grammY
|
||||
- 🎮 **Discord Bot** — DMs + guild channels via channels.discord.js
|
||||
- 🧩 **Mattermost Bot (plugin)** — Bot token + WebSocket events
|
||||
- 💬 **iMessage** — Local imsg CLI integration (macOS)
|
||||
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
|
||||
- ⏱️ **Streaming + chunking** — Block streaming + Telegram draft streaming details ([/concepts/streaming](/concepts/streaming))
|
||||
@@ -192,7 +190,6 @@ Example:
|
||||
- [Control UI (browser)](/web/control-ui)
|
||||
- [Telegram](/channels/telegram)
|
||||
- [Discord](/channels/discord)
|
||||
- [Mattermost (plugin)](/channels/mattermost)
|
||||
- [iMessage](/channels/imessage)
|
||||
- [Groups](/concepts/groups)
|
||||
- [WhatsApp group messages](/concepts/group-messages)
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ Local trust:
|
||||
- [Remote access (SSH)](/gateway/remote)
|
||||
- [Tailscale](/gateway/tailscale)
|
||||
|
||||
## Nodes + transports
|
||||
## Nodes + bridge
|
||||
|
||||
- [Nodes overview](/nodes)
|
||||
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
|
||||
|
||||
@@ -138,7 +138,7 @@ Notes:
|
||||
## Safety + practical limits
|
||||
|
||||
- Camera and microphone access trigger the usual OS permission prompts (and require usage strings in Info.plist).
|
||||
- Video clips are capped (currently `<= 60s`) to avoid oversized node payloads (base64 overhead + message limits).
|
||||
- Video clips are capped (currently `<= 60s`) to avoid oversized bridge payloads (base64 overhead + message limits).
|
||||
|
||||
## macOS screen video (OS-level)
|
||||
|
||||
|
||||
+15
-20
@@ -8,11 +8,9 @@ read_when:
|
||||
|
||||
# Nodes
|
||||
|
||||
A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
|
||||
A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Bridge protocol details: [Bridge protocol](/gateway/bridge-protocol).
|
||||
|
||||
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; deprecated/removed for current nodes).
|
||||
|
||||
macOS can also run in **node mode**: the menubar app connects to the Gateway’s WS server and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
|
||||
macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
|
||||
|
||||
Notes:
|
||||
- Nodes are **peripherals**, not gateways. They don’t run the gateway service.
|
||||
@@ -20,23 +18,21 @@ Notes:
|
||||
|
||||
## Pairing + status
|
||||
|
||||
**WS nodes use device pairing.** Nodes present a device identity during `connect`; the Gateway
|
||||
creates a device pairing request for `role: node`. Approve via the devices CLI (or UI).
|
||||
Pairing is gateway-owned and approval-based. See [Gateway pairing](/gateway/pairing) for the full flow.
|
||||
|
||||
Quick CLI:
|
||||
|
||||
```bash
|
||||
clawdbot devices list
|
||||
clawdbot devices approve <requestId>
|
||||
clawdbot devices reject <requestId>
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
clawdbot nodes reject <requestId>
|
||||
clawdbot nodes status
|
||||
clawdbot nodes describe --node <idOrNameOrIp>
|
||||
clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `nodes status` marks a node as **paired** when its device pairing role includes `node`.
|
||||
- `node.pair.*` (CLI: `clawdbot nodes pending/approve/reject`) is a separate gateway-owned
|
||||
node pairing store; it does **not** gate the WS `connect` handshake.
|
||||
- `nodes rename` stores a display name override in the gateway pairing store.
|
||||
|
||||
## Remote node host (system.run)
|
||||
|
||||
@@ -61,7 +57,7 @@ clawdbot node run --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
|
||||
```bash
|
||||
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
clawdbot node restart
|
||||
clawdbot node start
|
||||
```
|
||||
|
||||
### Pair + name
|
||||
@@ -243,7 +239,6 @@ Notes:
|
||||
- `system.notify` respects notification permission state on the macOS app.
|
||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||
- macOS nodes drop `PATH` overrides; headless node hosts only accept `PATH` when it prepends the node host PATH.
|
||||
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
|
||||
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
|
||||
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
|
||||
@@ -280,26 +275,26 @@ Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by
|
||||
## Headless node host (cross-platform)
|
||||
|
||||
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
|
||||
WebSocket and exposes `system.run` / `system.which`. This is useful on Linux/Windows
|
||||
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
|
||||
or for running a minimal node alongside a server.
|
||||
|
||||
Start it:
|
||||
|
||||
```bash
|
||||
clawdbot node run --host <gateway-host> --port 18789
|
||||
clawdbot node run --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Pairing is still required (the Gateway will show a node approval prompt).
|
||||
- The node host stores its node id, token, display name, and gateway connection info in `~/.clawdbot/node.json`.
|
||||
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
||||
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
||||
(see [Exec approvals](/tools/exec-approvals)).
|
||||
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
|
||||
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
|
||||
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
|
||||
- Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS.
|
||||
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
||||
|
||||
## Mac node mode
|
||||
|
||||
- The macOS menubar app connects to the Gateway WS server as a node (so `clawdbot nodes …` works against this Mac).
|
||||
- In remote mode, the app opens an SSH tunnel for the Gateway port and connects to `localhost`.
|
||||
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
||||
- In remote mode, the app opens an SSH tunnel for the bridge port and connects to `localhost`.
|
||||
@@ -41,7 +41,7 @@ Notes:
|
||||
|
||||
Who receives it:
|
||||
- All WebSocket clients (macOS app, WebChat, etc.)
|
||||
- All connected nodes (iOS/Android), and also on node connect as an initial “current state” push.
|
||||
- All connected bridge nodes (iOS/Android), and also on node connect as an initial “current state” push.
|
||||
|
||||
## Client behavior
|
||||
|
||||
@@ -53,9 +53,9 @@ Who receives it:
|
||||
### iOS node
|
||||
|
||||
- Uses the global list for `VoiceWakeManager` trigger detection.
|
||||
- Editing Wake Words in Settings calls `voicewake.set` (over the Gateway WS) and also keeps local wake-word detection responsive.
|
||||
- Editing Wake Words in Settings calls `voicewake.set` (over the bridge) and also keeps local wake-word detection responsive.
|
||||
|
||||
### Android node
|
||||
|
||||
- Exposes a Wake Words editor in Settings.
|
||||
- Calls `voicewake.set` over the Gateway WS so edits sync everywhere.
|
||||
- Calls `voicewake.set` over the bridge so edits sync everywhere.
|
||||
@@ -129,6 +129,7 @@ CLAWDBOT_IMAGE=clawdbot:latest
|
||||
CLAWDBOT_GATEWAY_TOKEN=change-me-now
|
||||
CLAWDBOT_GATEWAY_BIND=lan
|
||||
CLAWDBOT_GATEWAY_PORT=18789
|
||||
CLAWDBOT_BRIDGE_PORT=18790
|
||||
|
||||
CLAWDBOT_CONFIG_DIR=/root/.clawdbot
|
||||
CLAWDBOT_WORKSPACE_DIR=/root/clawd
|
||||
@@ -165,6 +166,7 @@ services:
|
||||
- TERM=xterm-256color
|
||||
- CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
|
||||
- CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
|
||||
- CLAWDBOT_BRIDGE_PORT=${CLAWDBOT_BRIDGE_PORT}
|
||||
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
|
||||
- GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
|
||||
- XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
|
||||
@@ -177,8 +179,9 @@ services:
|
||||
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
|
||||
- "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789"
|
||||
|
||||
# Optional: only if you run iOS/Android nodes against this VPS and need Canvas host.
|
||||
# If you expose this publicly, read /gateway/security and firewall accordingly.
|
||||
# Optional: only if you run iOS/Android nodes against this VPS.
|
||||
# If you expose these publicly, read /gateway/security and firewall accordingly.
|
||||
# - "${CLAWDBOT_BRIDGE_PORT}:18790"
|
||||
# - "18793:18793"
|
||||
command:
|
||||
[
|
||||
|
||||
@@ -40,7 +40,7 @@ node commands return `CANVAS_DISABLED`.
|
||||
|
||||
## Agent API surface
|
||||
|
||||
Canvas is exposed via the **Gateway WebSocket**, so the agent can:
|
||||
Canvas is exposed via the **node bridge**, so the agent can:
|
||||
|
||||
- show/hide the panel
|
||||
- navigate to a path or URL
|
||||
|
||||
@@ -45,13 +45,6 @@ present. To reset manually:
|
||||
rm ~/.clawdbot/disable-launchagent
|
||||
```
|
||||
|
||||
## Attach-only mode
|
||||
|
||||
To force the macOS app to **never install or manage launchd**, launch it with
|
||||
`--attach-only` (or `--no-launchd`). This sets `~/.clawdbot/disable-launchagent`,
|
||||
so the app only attaches to an already running Gateway. You can toggle the same
|
||||
behavior in Debug Settings.
|
||||
|
||||
## Remote mode
|
||||
|
||||
Remote mode never starts a local Gateway. The app uses an SSH tunnel to the
|
||||
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
## What is shown
|
||||
- We surface the current agent work state in the menu bar icon and in the first status row of the menu.
|
||||
- Health status is hidden while work is active; it returns when all sessions are idle.
|
||||
- The “Nodes” block in the menu lists **devices** only (paired nodes via `node.list`), not client/presence entries.
|
||||
- The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries.
|
||||
- A “Usage” section appears under Context when provider usage snapshots are available.
|
||||
|
||||
## State model
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "macOS IPC architecture for Clawdbot app, gateway node transport, and PeekabooBridge"
|
||||
summary: "macOS IPC architecture for Clawdbot app, gateway node bridge, and PeekabooBridge"
|
||||
read_when:
|
||||
- Editing IPC contracts or menu bar app IPC
|
||||
---
|
||||
@@ -13,21 +13,21 @@ read_when:
|
||||
- Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick.
|
||||
|
||||
## How it works
|
||||
### Gateway + node transport
|
||||
### Gateway + node bridge
|
||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||
|
||||
### Node service + app IPC
|
||||
- A headless node host service connects to the Gateway WebSocket.
|
||||
- A headless node host service connects to the Gateway bridge.
|
||||
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
||||
- The app performs the exec in UI context, prompts if needed, and returns output.
|
||||
|
||||
Diagram (SCI):
|
||||
```
|
||||
Agent -> Gateway -> Node Service (WS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
```
|
||||
|
||||
### PeekabooBridge (UI automation)
|
||||
|
||||
@@ -62,7 +62,7 @@ Node service + app IPC:
|
||||
|
||||
Diagram (SCI):
|
||||
```
|
||||
Gateway -> Node Service (WS)
|
||||
Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
@@ -99,7 +99,7 @@ Example:
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `allowlist` entries are glob patterns for resolved binary paths.
|
||||
- `allowlist` entries are JSON-encoded argv arrays.
|
||||
- Choosing “Always Allow” in the prompt adds that command to the allowlist.
|
||||
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the app’s environment.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ read_when:
|
||||
Clawdbot can use many LLM providers. Pick a provider, authenticate, then set the
|
||||
default model as `provider/model`.
|
||||
|
||||
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
|
||||
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Channels](/channels).
|
||||
|
||||
## Quick start
|
||||
|
||||
|
||||
+2
-2
@@ -6,14 +6,14 @@ read_when:
|
||||
---
|
||||
# Building a personal assistant with Clawdbot (Clawd-style)
|
||||
|
||||
Clawdbot is a WhatsApp + Telegram + Discord + iMessage gateway for **Pi** agents. Plugins add Mattermost. This guide is the "personal assistant" setup: one dedicated WhatsApp number that behaves like your always-on agent.
|
||||
Clawdbot is a WhatsApp + Telegram + Discord gateway for **Pi** agents. This guide is the “personal assistant” setup: one dedicated WhatsApp number that behaves like your always-on agent.
|
||||
|
||||
## ⚠️ Safety first
|
||||
|
||||
You’re putting an agent in a position to:
|
||||
- run commands on your machine (depending on your Pi tool setup)
|
||||
- read/write files in your workspace
|
||||
- send messages back out via WhatsApp/Telegram/Discord/Mattermost (plugin)
|
||||
- send messages back out via WhatsApp/Telegram/Discord
|
||||
|
||||
Start conservative:
|
||||
- Always set `channels.whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||
|
||||
+27
-19
@@ -178,7 +178,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
|
||||
### What is Clawdbot, in one paragraph?
|
||||
|
||||
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
|
||||
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always‑on control plane; the assistant is the product.
|
||||
|
||||
## Quick start and first-run setup
|
||||
|
||||
@@ -235,7 +235,7 @@ Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** fo
|
||||
- **Model/auth setup** (Anthropic **setup-token** recommended for Claude subscriptions, OpenAI Codex OAuth supported, API keys optional, LM Studio local models supported)
|
||||
- **Workspace** location + bootstrap files
|
||||
- **Gateway settings** (bind/port/auth/tailscale)
|
||||
- **Providers** (WhatsApp, Telegram, Discord, Mattermost (plugin), Signal, iMessage)
|
||||
- **Providers** (WhatsApp, Telegram, Discord, Signal, iMessage)
|
||||
- **Daemon install** (LaunchAgent on macOS; systemd user unit on Linux/WSL2)
|
||||
- **Health checks** and **skills** selection
|
||||
|
||||
@@ -363,7 +363,7 @@ lowest friction and you’re okay with sleep/restarts, run it locally.
|
||||
- **Pros:** always‑on, stable network, no laptop sleep issues, easier to keep running.
|
||||
- **Cons:** often run headless (use screenshots), remote file access only, you must SSH for updates.
|
||||
|
||||
**Clawdbot-specific note:** WhatsApp/Telegram/Slack/Mattermost (plugin)/Discord all work fine from a VPS. The only real trade-off is **headless browser** vs a visible window. See [Browser](/tools/browser).
|
||||
**Clawdbot‑specific note:** WhatsApp/Telegram/Slack/Discord all work fine from a VPS. The only real trade‑off is **headless browser** vs a visible window. See [Browser](/tools/browser).
|
||||
|
||||
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
|
||||
|
||||
@@ -719,42 +719,43 @@ See the full config examples in [Browser](/tools/browser#use-brave-or-another-ch
|
||||
### How do commands propagate between Telegram, the gateway, and nodes?
|
||||
|
||||
Telegram messages are handled by the **gateway**. The gateway runs the agent and
|
||||
only then calls nodes over the **Gateway WebSocket** when a node tool is needed:
|
||||
only then calls nodes over the **Bridge** when a node tool is needed:
|
||||
|
||||
Telegram → Gateway → Agent → `node.*` → Node → Gateway → Telegram
|
||||
|
||||
Nodes don’t see inbound provider traffic; they only receive node RPC calls.
|
||||
Nodes don’t see inbound provider traffic; they only receive bridge RPC calls.
|
||||
|
||||
### How can my agent access my computer if the Gateway is hosted remotely?
|
||||
|
||||
Short answer: **pair your computer as a node**. The Gateway runs elsewhere, but it can
|
||||
call `node.*` tools (screen, camera, system) on your local machine over the Gateway WebSocket.
|
||||
call `node.*` tools (screen, camera, system) on your local machine over the Bridge.
|
||||
|
||||
Typical setup:
|
||||
1) Run the Gateway on the always‑on host (VPS/home server).
|
||||
2) Put the Gateway host + your computer on the same tailnet.
|
||||
3) Ensure the Gateway WS is reachable (tailnet bind or SSH tunnel).
|
||||
4) Open the macOS app locally and connect in **Remote over SSH** mode (or direct tailnet)
|
||||
so it can register as a node.
|
||||
3) Enable the bridge on the Gateway host:
|
||||
```json5
|
||||
{ bridge: { enabled: true, bind: "auto" } }
|
||||
```
|
||||
4) Open the macOS app locally and connect in **Remote over SSH** mode so it can tunnel
|
||||
the bridge port and register as a node.
|
||||
5) Approve the node on the Gateway:
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
No separate TCP bridge is required; nodes connect over the Gateway WebSocket.
|
||||
|
||||
Security reminder: pairing a macOS node allows `system.run` on that machine. Only
|
||||
pair devices you trust, and review [Security](/gateway/security).
|
||||
|
||||
Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
||||
Docs: [Nodes](/nodes), [Bridge protocol](/gateway/bridge-protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
|
||||
|
||||
### Do nodes run a gateway service?
|
||||
|
||||
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
|
||||
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
||||
|
||||
A full restart is required for `gateway`, `discovery`, and `canvasHost` changes.
|
||||
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
|
||||
|
||||
### Is there an API / RPC way to apply config?
|
||||
|
||||
@@ -796,19 +797,26 @@ This keeps the gateway bound to loopback and exposes HTTPS via Tailscale. See [T
|
||||
|
||||
### How do I connect a Mac node to a remote Gateway (Tailscale Serve)?
|
||||
|
||||
Serve exposes the **Gateway Control UI + WS**. Nodes connect over the same Gateway WS endpoint.
|
||||
Serve only exposes the **Gateway Control UI**. Nodes use the **bridge port**.
|
||||
|
||||
Recommended setup:
|
||||
1) **Make sure the VPS + Mac are on the same tailnet**.
|
||||
2) **Use the macOS app in Remote mode** (SSH target can be the tailnet hostname).
|
||||
The app will tunnel the Gateway port and connect as a node.
|
||||
3) **Approve the node** on the gateway:
|
||||
1) **Enable the bridge on the gateway host**:
|
||||
```json5
|
||||
{
|
||||
bridge: { enabled: true, bind: "auto" }
|
||||
}
|
||||
```
|
||||
`auto` prefers a tailnet IP when Tailscale is present.
|
||||
2) **Make sure the VPS + Mac are on the same tailnet**.
|
||||
3) **Use the macOS app in Remote mode** (SSH target can be the tailnet hostname).
|
||||
The app will tunnel the bridge port and connect as a node.
|
||||
4) **Approve the node** on the gateway:
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
Docs: [Gateway protocol](/gateway/protocol), [Discovery](/gateway/discovery), [macOS remote mode](/platforms/mac/remote).
|
||||
Docs: [Bridge protocol](/gateway/bridge-protocol), [Discovery](/gateway/discovery), [macOS remote mode](/platforms/mac/remote).
|
||||
|
||||
## Env vars and .env loading
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Goal: go from **zero** → **first working chat** (with sane defaults) as quickl
|
||||
Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up:
|
||||
- model/auth (OAuth recommended)
|
||||
- gateway settings
|
||||
- channels (WhatsApp/Telegram/Discord/Mattermost (plugin)/...)
|
||||
- channels (WhatsApp/Telegram/Discord/…)
|
||||
- pairing defaults (secure DMs)
|
||||
- workspace bootstrap + skills
|
||||
- optional background service
|
||||
@@ -80,7 +80,7 @@ clawdbot onboard --install-daemon
|
||||
What you’ll choose:
|
||||
- **Local vs Remote** gateway
|
||||
- **Auth**: OpenAI Code (Codex) subscription (OAuth) or API keys. For Anthropic we recommend an API key; `claude setup-token` is also supported.
|
||||
- **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, Mattermost plugin tokens, etc.
|
||||
- **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, etc.
|
||||
- **Daemon**: background install (launchd/systemd; WSL2 uses systemd)
|
||||
- **Runtime**: Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**.
|
||||
- **Gateway token**: the wizard generates one by default (even on loopback) and stores it in `gateway.auth.token`.
|
||||
@@ -140,7 +140,6 @@ WhatsApp doc: [WhatsApp](/channels/whatsapp)
|
||||
The wizard can write tokens/config for you. If you prefer manual config, start with:
|
||||
- Telegram: [Telegram](/channels/telegram)
|
||||
- Discord: [Discord](/channels/discord)
|
||||
- Mattermost (plugin): [Mattermost](/channels/mattermost)
|
||||
|
||||
**Telegram DM tip:** your first DM returns a pairing code. Approve it (see next step) or the bot won’t respond.
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [Telegram (grammY notes)](/channels/grammy)
|
||||
- [Slack](/channels/slack)
|
||||
- [Discord](/channels/discord)
|
||||
- [Mattermost](/channels/mattermost) (plugin)
|
||||
- [Signal](/channels/signal)
|
||||
- [iMessage](/channels/imessage)
|
||||
- [Location parsing](/channels/location)
|
||||
|
||||
+10
-12
@@ -45,29 +45,27 @@ Stored under `~/.clawdbot/credentials/`:
|
||||
Treat these as sensitive (they gate access to your assistant).
|
||||
|
||||
|
||||
## 2) Node device pairing (iOS/Android/macOS/headless nodes)
|
||||
## 2) Node pairing (iOS/Android nodes joining the gateway)
|
||||
|
||||
Nodes connect to the Gateway as **devices** with `role: node`. The Gateway
|
||||
creates a device pairing request that must be approved.
|
||||
Nodes (iOS/Android, future hardware, etc.) connect to the Gateway and request to join.
|
||||
The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject.
|
||||
|
||||
### Approve a node device
|
||||
### Approve a node
|
||||
|
||||
```bash
|
||||
clawdbot devices list
|
||||
clawdbot devices approve <requestId>
|
||||
clawdbot devices reject <requestId>
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
### Where the state lives
|
||||
|
||||
Stored under `~/.clawdbot/devices/`:
|
||||
Stored under `~/.clawdbot/nodes/`:
|
||||
- `pending.json` (short-lived; pending requests expire)
|
||||
- `paired.json` (paired devices + tokens)
|
||||
- `paired.json` (paired nodes + tokens)
|
||||
|
||||
### Notes
|
||||
### Details
|
||||
|
||||
- The legacy `node.pair.*` API (CLI: `clawdbot nodes pending/approve`) is a
|
||||
separate gateway-owned pairing store. WS nodes still require device pairing.
|
||||
Full protocol + design notes: [Gateway pairing](/gateway/pairing)
|
||||
|
||||
|
||||
## Related docs
|
||||
|
||||
@@ -48,7 +48,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or setup-token (paste), plus MiniMax/GLM/Moonshot/AI Gateway options)
|
||||
- Workspace location + bootstrap files
|
||||
- Gateway settings (port/bind/auth/tailscale)
|
||||
- Providers (Telegram, WhatsApp, Discord, Mattermost (plugin), Signal)
|
||||
- Providers (Telegram, WhatsApp, Discord, Signal)
|
||||
- Daemon install (LaunchAgent / systemd user unit)
|
||||
- Health check
|
||||
- Skills (recommended)
|
||||
@@ -117,7 +117,6 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- WhatsApp: optional QR login.
|
||||
- Telegram: bot token.
|
||||
- Discord: bot token.
|
||||
- Mattermost (plugin): bot token + base URL.
|
||||
- Signal: optional `signal-cli` install + account config.
|
||||
- iMessage: local `imsg` CLI path + DB access.
|
||||
- DM security: default is pairing. First DM sends a code; approve via `clawdbot pairing approve <channel> <code>` or use allowlists.
|
||||
|
||||
@@ -22,7 +22,7 @@ Exec approvals are enforced locally on the execution host:
|
||||
- **gateway host** → `clawdbot` process on the gateway machine
|
||||
- **node host** → node runner (macOS companion app or headless node host)
|
||||
|
||||
macOS split:
|
||||
Planned macOS split:
|
||||
- **node host service** forwards `system.run` to the **macOS app** over local IPC.
|
||||
- **macOS app** enforces approvals + executes the command in UI context.
|
||||
|
||||
@@ -103,8 +103,8 @@ Each allowlist entry tracks:
|
||||
## Auto-allow skill CLIs
|
||||
|
||||
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
||||
are treated as allowlisted on nodes (macOS node or headless node host). This uses
|
||||
`skills.bins` over the Gateway RPC to fetch the skill bin list. Disable this if you want strict manual allowlists.
|
||||
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
|
||||
gateway for the skill bin list. Disable this if you want strict manual allowlists.
|
||||
|
||||
## Safe bins (stdin-only)
|
||||
|
||||
@@ -113,9 +113,6 @@ that can run in allowlist mode **without** explicit allowlist entries. Safe bins
|
||||
positional file args and path-like tokens, so they can only operate on the incoming stream.
|
||||
Shell chaining and redirections are not auto-allowed in allowlist mode.
|
||||
|
||||
Shell chaining (`&&`, `||`, `;`) is allowed when every top-level segment satisfies the allowlist
|
||||
(including safe bins or skill auto-allow). Redirections remain unsupported in allowlist mode.
|
||||
|
||||
Default safe bins: `jq`, `grep`, `cut`, `sort`, `uniq`, `head`, `tail`, `tr`, `wc`.
|
||||
|
||||
## Control UI editing
|
||||
@@ -154,12 +151,12 @@ Actions:
|
||||
- **Always allow** → add to allowlist + run
|
||||
- **Deny** → block
|
||||
|
||||
### macOS IPC flow
|
||||
### macOS IPC flow (planned)
|
||||
```
|
||||
Gateway -> Node Service (WS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + approvals + system.run)
|
||||
Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + approvals + system.run)
|
||||
```
|
||||
|
||||
Security notes:
|
||||
|
||||
+2
-2
@@ -66,8 +66,8 @@ Example:
|
||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
|
||||
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
||||
if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
|
||||
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
|
||||
if the exec call already sets `env.PATH`. Node PATH overrides are accepted only when they prepend
|
||||
the node host PATH (no replacement).
|
||||
|
||||
Per-agent node binding (use the agent list index in config):
|
||||
|
||||
|
||||
@@ -88,12 +88,6 @@ Session lifecycle:
|
||||
- `/settings`
|
||||
- `/exit`
|
||||
|
||||
## Local shell commands
|
||||
- Prefix a line with `!` to run a local shell command on the TUI host.
|
||||
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
|
||||
- Commands run in a fresh, non-interactive shell in the TUI working directory (no persistent `cd`/env).
|
||||
- A lone `!` is sent as a normal message; leading spaces do not trigger local exec.
|
||||
|
||||
## Tool output
|
||||
- Tool calls show as cards with args + results.
|
||||
- Ctrl+O toggles between collapsed/expanded views.
|
||||
|
||||
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
## What it can do (today)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Channels: WhatsApp/Telegram status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
|
||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"id": "mattermost",
|
||||
"channels": [
|
||||
"mattermost"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { mattermostPlugin } from "./src/channel.js";
|
||||
import { setMattermostRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "mattermost",
|
||||
name: "Mattermost",
|
||||
description: "Mattermost channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMattermostRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: mattermostPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "@clawdbot/mattermost",
|
||||
"version": "2026.1.20-2",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Mattermost channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "mattermost",
|
||||
"label": "Mattermost",
|
||||
"selectionLabel": "Mattermost (plugin)",
|
||||
"docsPath": "/channels/mattermost",
|
||||
"docsLabel": "mattermost",
|
||||
"blurb": "self-hosted Slack-style chat; install the plugin to enable.",
|
||||
"order": 65
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@clawdbot/mattermost",
|
||||
"localPath": "extensions/mattermost",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mattermostPlugin } from "./channel.js";
|
||||
|
||||
describe("mattermostPlugin", () => {
|
||||
describe("messaging", () => {
|
||||
it("keeps @username targets", () => {
|
||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) return;
|
||||
|
||||
expect(normalize("@Alice")).toBe("@Alice");
|
||||
expect(normalize("@alice")).toBe("@alice");
|
||||
});
|
||||
|
||||
it("normalizes mattermost: prefix to user:", () => {
|
||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) return;
|
||||
|
||||
expect(normalize("mattermost:USER123")).toBe("user:USER123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairing", () => {
|
||||
it("normalizes allowlist entries", () => {
|
||||
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
|
||||
if (!normalize) return;
|
||||
|
||||
expect(normalize("@Alice")).toBe("alice");
|
||||
expect(normalize("user:USER123")).toBe("user123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
it("formats allowFrom entries", () => {
|
||||
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom;
|
||||
|
||||
const formatted = formatAllowFrom({
|
||||
allowFrom: ["@Alice", "user:USER123", "mattermost:BOT999"],
|
||||
});
|
||||
expect(formatted).toEqual(["@alice", "user123", "bot999"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { MattermostConfigSchema } from "./config-schema.js";
|
||||
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
|
||||
import {
|
||||
looksLikeMattermostTargetId,
|
||||
normalizeMattermostMessagingTarget,
|
||||
} from "./normalize.js";
|
||||
import { mattermostOnboardingAdapter } from "./onboarding.js";
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
type ResolvedMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
||||
import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||
import { probeMattermost } from "./mattermost/probe.js";
|
||||
import { sendMessageMattermost } from "./mattermost/send.js";
|
||||
import { getMattermostRuntime } from "./runtime.js";
|
||||
|
||||
const meta = {
|
||||
id: "mattermost",
|
||||
label: "Mattermost",
|
||||
selectionLabel: "Mattermost (plugin)",
|
||||
detailLabel: "Mattermost Bot",
|
||||
docsPath: "/channels/mattermost",
|
||||
docsLabel: "mattermost",
|
||||
blurb: "self-hosted Slack-style chat; install the plugin to enable.",
|
||||
systemImage: "bubble.left.and.bubble.right",
|
||||
order: 65,
|
||||
quickstartAllowFrom: true,
|
||||
} as const;
|
||||
|
||||
function normalizeAllowEntry(entry: string): string {
|
||||
return entry
|
||||
.trim()
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function formatAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.startsWith("@")) {
|
||||
const username = trimmed.slice(1).trim();
|
||||
return username ? `@${username.toLowerCase()}` : "";
|
||||
}
|
||||
return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase();
|
||||
}
|
||||
|
||||
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
id: "mattermost",
|
||||
meta: {
|
||||
...meta,
|
||||
},
|
||||
onboarding: mattermostOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "mattermostUserId",
|
||||
normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
|
||||
notifyApproval: async ({ id }) => {
|
||||
console.log(`[mattermost] User ${id} approved for pairing`);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "group", "thread"],
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.mattermost"] },
|
||||
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listMattermostAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "mattermost",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "mattermost",
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "baseUrl", "name"],
|
||||
}),
|
||||
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.botToken && account.baseUrl),
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveMattermostAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
||||
String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => formatAllowEntry(String(entry)))
|
||||
.filter(Boolean),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(cfg.channels?.mattermost?.accounts?.[resolvedAccountId]);
|
||||
const basePath = useAccountPath
|
||||
? `channels.mattermost.accounts.${resolvedAccountId}.`
|
||||
: "channels.mattermost.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: formatPairingApproveHint("mattermost"),
|
||||
normalizeEntry: (raw) => normalizeAllowEntry(raw),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") return [];
|
||||
return [
|
||||
`- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`,
|
||||
];
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMattermostMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeMattermostTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||
const result = await sendMessageMattermost(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "mattermost", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const result = await sendMessageMattermost(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
replyToId: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "mattermost", ...result };
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
connected: false,
|
||||
lastConnectedAt: null,
|
||||
lastDisconnect: null,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
botTokenSource: snapshot.botTokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
connected: snapshot.connected ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
baseUrl: snapshot.baseUrl ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) => {
|
||||
const token = account.botToken?.trim();
|
||||
const baseUrl = account.baseUrl?.trim();
|
||||
if (!token || !baseUrl) {
|
||||
return { ok: false, error: "bot token or baseUrl missing" };
|
||||
}
|
||||
return await probeMattermost(baseUrl, token, timeoutMs);
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.botToken && account.baseUrl),
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
running: runtime?.running ?? false,
|
||||
connected: runtime?.connected ?? false,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
lastDisconnect: runtime?.lastDisconnect ?? null,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "mattermost",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "Mattermost env vars can only be used for the default account.";
|
||||
}
|
||||
const token = input.botToken ?? input.token;
|
||||
const baseUrl = input.httpUrl;
|
||||
if (!input.useEnv && (!token || !baseUrl)) {
|
||||
return "Mattermost requires --bot-token and --http-url (or --use-env).";
|
||||
}
|
||||
if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) {
|
||||
return "Mattermost --http-url must include a valid base URL.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const token = input.botToken ?? input.token;
|
||||
const baseUrl = input.httpUrl?.trim();
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "mattermost",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "mattermost",
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
...(input.useEnv
|
||||
? {}
|
||||
: {
|
||||
...(token ? { botToken: token } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.mattermost?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.mattermost?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...(token ? { botToken: token } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
baseUrl: account.baseUrl,
|
||||
botTokenSource: account.botTokenSource,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting channel`);
|
||||
return monitorMattermostProvider({
|
||||
botToken: account.botToken ?? undefined,
|
||||
baseUrl: account.baseUrl ?? undefined,
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
|
||||
oncharPrefixes: z.array(z.string()).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMattermostAccount } from "./mattermost/accounts.js";
|
||||
|
||||
export function resolveMattermostGroupRequireMention(
|
||||
params: ChannelGroupContext,
|
||||
): boolean | undefined {
|
||||
const account = resolveMattermostAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (typeof account.requireMention === "boolean") return account.requireMention;
|
||||
return true;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
|
||||
import { normalizeMattermostBaseUrl } from "./client.js";
|
||||
|
||||
export type MattermostTokenSource = "env" | "config" | "none";
|
||||
export type MattermostBaseUrlSource = "env" | "config" | "none";
|
||||
|
||||
export type ResolvedMattermostAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
botTokenSource: MattermostTokenSource;
|
||||
baseUrlSource: MattermostBaseUrlSource;
|
||||
config: MattermostAccountConfig;
|
||||
chatmode?: MattermostChatMode;
|
||||
oncharPrefixes?: string[];
|
||||
requireMention?: boolean;
|
||||
textChunkLimit?: number;
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const accounts = cfg.channels?.mattermost?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listMattermostAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||
return ids.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultMattermostAccountId(cfg: ClawdbotConfig): string {
|
||||
const ids = listMattermostAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig | undefined {
|
||||
const accounts = cfg.channels?.mattermost?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as MattermostAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeMattermostAccountConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ??
|
||||
{}) as MattermostAccountConfig & { accounts?: unknown };
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
||||
if (config.chatmode === "oncall") return true;
|
||||
if (config.chatmode === "onmessage") return false;
|
||||
if (config.chatmode === "onchar") return true;
|
||||
return config.requireMention;
|
||||
}
|
||||
|
||||
export function resolveMattermostAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedMattermostAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
|
||||
const merged = mergeMattermostAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
|
||||
const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
|
||||
const configToken = merged.botToken?.trim();
|
||||
const configUrl = merged.baseUrl?.trim();
|
||||
const botToken = configToken || envToken;
|
||||
const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
|
||||
const requireMention = resolveMattermostRequireMention(merged);
|
||||
|
||||
const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
|
||||
const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
botToken,
|
||||
baseUrl,
|
||||
botTokenSource,
|
||||
baseUrlSource,
|
||||
config: merged,
|
||||
chatmode: merged.chatmode,
|
||||
oncharPrefixes: merged.oncharPrefixes,
|
||||
requireMention,
|
||||
textChunkLimit: merged.textChunkLimit,
|
||||
blockStreaming: merged.blockStreaming,
|
||||
blockStreamingCoalesce: merged.blockStreamingCoalesce,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMattermostAccount[] {
|
||||
return listMattermostAccountIds(cfg)
|
||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
export type MattermostClient = {
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
token: string;
|
||||
request: <T>(path: string, init?: RequestInit) => Promise<T>;
|
||||
};
|
||||
|
||||
export type MattermostUser = {
|
||||
id: string;
|
||||
username?: string | null;
|
||||
nickname?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
};
|
||||
|
||||
export type MattermostChannel = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
display_name?: string | null;
|
||||
type?: string | null;
|
||||
team_id?: string | null;
|
||||
};
|
||||
|
||||
export type MattermostPost = {
|
||||
id: string;
|
||||
user_id?: string | null;
|
||||
channel_id?: string | null;
|
||||
message?: string | null;
|
||||
file_ids?: string[] | null;
|
||||
type?: string | null;
|
||||
root_id?: string | null;
|
||||
create_at?: number | null;
|
||||
props?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type MattermostFileInfo = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
mime_type?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const withoutTrailing = trimmed.replace(/\/+$/, "");
|
||||
return withoutTrailing.replace(/\/api\/v4$/i, "");
|
||||
}
|
||||
|
||||
function buildMattermostApiUrl(baseUrl: string, path: string): string {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) throw new Error("Mattermost baseUrl is required");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${normalized}/api/v4${suffix}`;
|
||||
}
|
||||
|
||||
async function readMattermostError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await res.json()) as { message?: string } | undefined;
|
||||
if (data?.message) return data.message;
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export function createMattermostClient(params: {
|
||||
baseUrl: string;
|
||||
botToken: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): MattermostClient {
|
||||
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
|
||||
if (!baseUrl) throw new Error("Mattermost baseUrl is required");
|
||||
const apiBaseUrl = `${baseUrl}/api/v4`;
|
||||
const token = params.botToken.trim();
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const url = buildMattermostApiUrl(baseUrl, path);
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (typeof init?.body === "string" && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const res = await fetchImpl(url, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(
|
||||
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
return { baseUrl, apiBaseUrl, token, request };
|
||||
}
|
||||
|
||||
export async function fetchMattermostMe(client: MattermostClient): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>("/users/me");
|
||||
}
|
||||
|
||||
export async function fetchMattermostUser(
|
||||
client: MattermostClient,
|
||||
userId: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostUserByUsername(
|
||||
client: MattermostClient,
|
||||
username: string,
|
||||
): Promise<MattermostUser> {
|
||||
return await client.request<MattermostUser>(`/users/username/${encodeURIComponent(username)}`);
|
||||
}
|
||||
|
||||
export async function fetchMattermostChannel(
|
||||
client: MattermostClient,
|
||||
channelId: string,
|
||||
): Promise<MattermostChannel> {
|
||||
return await client.request<MattermostChannel>(`/channels/${channelId}`);
|
||||
}
|
||||
|
||||
export async function sendMattermostTyping(
|
||||
client: MattermostClient,
|
||||
params: { channelId: string; parentId?: string },
|
||||
): Promise<void> {
|
||||
const payload: Record<string, string> = {
|
||||
channel_id: params.channelId,
|
||||
};
|
||||
const parentId = params.parentId?.trim();
|
||||
if (parentId) payload.parent_id = parentId;
|
||||
await client.request<Record<string, unknown>>("/users/me/typing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMattermostDirectChannel(
|
||||
client: MattermostClient,
|
||||
userIds: string[],
|
||||
): Promise<MattermostChannel> {
|
||||
return await client.request<MattermostChannel>("/channels/direct", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userIds),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMattermostPost(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
message: string;
|
||||
rootId?: string;
|
||||
fileIds?: string[];
|
||||
},
|
||||
): Promise<MattermostPost> {
|
||||
const payload: Record<string, string> = {
|
||||
channel_id: params.channelId,
|
||||
message: params.message,
|
||||
};
|
||||
if (params.rootId) payload.root_id = params.rootId;
|
||||
if (params.fileIds?.length) {
|
||||
(payload as Record<string, unknown>).file_ids = params.fileIds;
|
||||
}
|
||||
return await client.request<MattermostPost>("/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadMattermostFile(
|
||||
client: MattermostClient,
|
||||
params: {
|
||||
channelId: string;
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
contentType?: string;
|
||||
},
|
||||
): Promise<MattermostFileInfo> {
|
||||
const form = new FormData();
|
||||
const fileName = params.fileName?.trim() || "upload";
|
||||
const bytes = Uint8Array.from(params.buffer);
|
||||
const blob = params.contentType
|
||||
? new Blob([bytes], { type: params.contentType })
|
||||
: new Blob([bytes]);
|
||||
form.append("files", blob, fileName);
|
||||
form.append("channel_id", params.channelId);
|
||||
|
||||
const res = await fetch(`${client.apiBaseUrl}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { file_infos?: MattermostFileInfo[] };
|
||||
const info = data.file_infos?.[0];
|
||||
if (!info?.id) {
|
||||
throw new Error("Mattermost file upload failed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export {
|
||||
listEnabledMattermostAccounts,
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
} from "./accounts.js";
|
||||
export { monitorMattermostProvider } from "./monitor.js";
|
||||
export { probeMattermost } from "./probe.js";
|
||||
export { sendMessageMattermost } from "./send.js";
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import type WebSocket from "ws";
|
||||
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type ResponsePrefixContext = {
|
||||
model?: string;
|
||||
modelFull?: string;
|
||||
provider?: string;
|
||||
thinkingLevel?: string;
|
||||
identityName?: string;
|
||||
};
|
||||
|
||||
export function extractShortModelName(fullModel: string): string {
|
||||
const slash = fullModel.lastIndexOf("/");
|
||||
const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
|
||||
return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
|
||||
}
|
||||
|
||||
export function formatInboundFromLabel(params: {
|
||||
isGroup: boolean;
|
||||
groupLabel?: string;
|
||||
groupId?: string;
|
||||
directLabel: string;
|
||||
directId?: string;
|
||||
groupFallback?: string;
|
||||
}): string {
|
||||
if (params.isGroup) {
|
||||
const label = params.groupLabel?.trim() || params.groupFallback || "Group";
|
||||
const id = params.groupId?.trim();
|
||||
return id ? `${label} id:${id}` : label;
|
||||
}
|
||||
|
||||
const directLabel = params.directLabel.trim();
|
||||
const directId = params.directId?.trim();
|
||||
if (!directId || directId === directLabel) return directLabel;
|
||||
return `${directLabel} id:${directId}`;
|
||||
}
|
||||
|
||||
type DedupeCache = {
|
||||
check: (key: string | undefined | null, now?: number) => boolean;
|
||||
};
|
||||
|
||||
export function createDedupeCache(options: { ttlMs: number; maxSize: number }): DedupeCache {
|
||||
const ttlMs = Math.max(0, options.ttlMs);
|
||||
const maxSize = Math.max(0, Math.floor(options.maxSize));
|
||||
const cache = new Map<string, number>();
|
||||
|
||||
const touch = (key: string, now: number) => {
|
||||
cache.delete(key);
|
||||
cache.set(key, now);
|
||||
};
|
||||
|
||||
const prune = (now: number) => {
|
||||
const cutoff = ttlMs > 0 ? now - ttlMs : undefined;
|
||||
if (cutoff !== undefined) {
|
||||
for (const [entryKey, entryTs] of cache) {
|
||||
if (entryTs < cutoff) {
|
||||
cache.delete(entryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxSize <= 0) {
|
||||
cache.clear();
|
||||
return;
|
||||
}
|
||||
while (cache.size > maxSize) {
|
||||
const oldestKey = cache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
check: (key, now = Date.now()) => {
|
||||
if (!key) return false;
|
||||
const existing = cache.get(key);
|
||||
if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
|
||||
touch(key, now);
|
||||
return true;
|
||||
}
|
||||
touch(key, now);
|
||||
prune(now);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function rawDataToString(
|
||||
data: WebSocket.RawData,
|
||||
encoding: BufferEncoding = "utf8",
|
||||
): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString(encoding);
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString(encoding);
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString(encoding);
|
||||
}
|
||||
return Buffer.from(String(data)).toString(encoding);
|
||||
}
|
||||
|
||||
function normalizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return "main";
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.slice(0, 64) || "main"
|
||||
);
|
||||
}
|
||||
|
||||
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
|
||||
|
||||
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
|
||||
const list = cfg.agents?.list;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
||||
}
|
||||
|
||||
function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
|
||||
}
|
||||
|
||||
export function resolveIdentityName(cfg: ClawdbotConfig, agentId: string): string | undefined {
|
||||
const entry = resolveAgentEntry(cfg, agentId);
|
||||
return entry?.identity?.name?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function resolveThreadSessionKeys(params: {
|
||||
baseSessionKey: string;
|
||||
threadId?: string | null;
|
||||
parentSessionKey?: string;
|
||||
useSuffix?: boolean;
|
||||
}): { sessionKey: string; parentSessionKey?: string } {
|
||||
const threadId = (params.threadId ?? "").trim();
|
||||
if (!threadId) {
|
||||
return { sessionKey: params.baseSessionKey, parentSessionKey: undefined };
|
||||
}
|
||||
const useSuffix = params.useSuffix ?? true;
|
||||
const sessionKey = useSuffix
|
||||
? `${params.baseSessionKey}:thread:${threadId}`
|
||||
: params.baseSessionKey;
|
||||
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
||||
}
|
||||
@@ -1,905 +0,0 @@
|
||||
import WebSocket from "ws";
|
||||
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ClawdbotConfig,
|
||||
ReplyPayload,
|
||||
RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
clearHistoryEntries,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
recordPendingHistoryEntry,
|
||||
resolveChannelMediaMaxBytes,
|
||||
type HistoryEntry,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostChannel,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUser,
|
||||
normalizeMattermostBaseUrl,
|
||||
sendMattermostTyping,
|
||||
type MattermostChannel,
|
||||
type MattermostPost,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
import {
|
||||
createDedupeCache,
|
||||
extractShortModelName,
|
||||
formatInboundFromLabel,
|
||||
rawDataToString,
|
||||
resolveIdentityName,
|
||||
resolveThreadSessionKeys,
|
||||
type ResponsePrefixContext,
|
||||
} from "./monitor-helpers.js";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
|
||||
export type MonitorMattermostOpts = {
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
config?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
||||
};
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||
|
||||
type MattermostEventPayload = {
|
||||
event?: string;
|
||||
data?: {
|
||||
post?: string;
|
||||
channel_id?: string;
|
||||
channel_name?: string;
|
||||
channel_display_name?: string;
|
||||
channel_type?: string;
|
||||
sender_name?: string;
|
||||
team_id?: string;
|
||||
};
|
||||
broadcast?: {
|
||||
channel_id?: string;
|
||||
team_id?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
|
||||
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
|
||||
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
||||
const USER_CACHE_TTL_MS = 10 * 60_000;
|
||||
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
|
||||
|
||||
const recentInboundMessages = createDedupeCache({
|
||||
ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS,
|
||||
maxSize: RECENT_MATTERMOST_MESSAGE_MAX,
|
||||
});
|
||||
|
||||
function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
|
||||
return (
|
||||
opts.runtime ?? {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMention(text: string, mention: string | undefined): string {
|
||||
if (!mention) return text.trim();
|
||||
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`@${escaped}\\b`, "gi");
|
||||
return text.replace(re, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
|
||||
const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES;
|
||||
return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
|
||||
}
|
||||
|
||||
function stripOncharPrefix(
|
||||
text: string,
|
||||
prefixes: string[],
|
||||
): { triggered: boolean; stripped: string } {
|
||||
const trimmed = text.trimStart();
|
||||
for (const prefix of prefixes) {
|
||||
if (!prefix) continue;
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return {
|
||||
triggered: true,
|
||||
stripped: trimmed.slice(prefix.length).trimStart(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { triggered: false, stripped: text };
|
||||
}
|
||||
|
||||
function isSystemPost(post: MattermostPost): boolean {
|
||||
const type = post.type?.trim();
|
||||
return Boolean(type);
|
||||
}
|
||||
|
||||
function channelKind(channelType?: string | null): "dm" | "group" | "channel" {
|
||||
if (!channelType) return "channel";
|
||||
const normalized = channelType.trim().toUpperCase();
|
||||
if (normalized === "D") return "dm";
|
||||
if (normalized === "G") return "group";
|
||||
return "channel";
|
||||
}
|
||||
|
||||
function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" {
|
||||
if (kind === "dm") return "direct";
|
||||
if (kind === "group") return "group";
|
||||
return "channel";
|
||||
}
|
||||
|
||||
function normalizeAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed === "*") return "*";
|
||||
return trimmed
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeAllowList(entries: Array<string | number>): string[] {
|
||||
const normalized = entries
|
||||
.map((entry) => normalizeAllowEntry(String(entry)))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
function isSenderAllowed(params: {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
allowFrom: string[];
|
||||
}): boolean {
|
||||
const allowFrom = params.allowFrom;
|
||||
if (allowFrom.length === 0) return false;
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedSenderId = normalizeAllowEntry(params.senderId);
|
||||
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
||||
return allowFrom.some(
|
||||
(entry) =>
|
||||
entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName),
|
||||
);
|
||||
}
|
||||
|
||||
type MattermostMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
kind: MediaKind;
|
||||
};
|
||||
|
||||
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
|
||||
if (mediaList.length === 0) return "";
|
||||
if (mediaList.length === 1) {
|
||||
const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
|
||||
return `<media:${kind}>`;
|
||||
}
|
||||
const allImages = mediaList.every((media) => media.kind === "image");
|
||||
const label = allImages ? "image" : "file";
|
||||
const suffix = mediaList.length === 1 ? label : `${label}s`;
|
||||
const tag = allImages ? "<media:image>" : "<media:document>";
|
||||
return `${tag} (${mediaList.length} ${suffix})`;
|
||||
}
|
||||
|
||||
function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
const first = mediaList[0];
|
||||
const mediaPaths = mediaList.map((media) => media.path);
|
||||
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
||||
return {
|
||||
MediaPath: first?.path,
|
||||
MediaType: first?.contentType,
|
||||
MediaUrl: first?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMattermostWsUrl(baseUrl: string): string {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) throw new Error("Mattermost baseUrl is required");
|
||||
const wsBase = normalized.replace(/^http/i, "ws");
|
||||
return `${wsBase}/api/v4/websocket`;
|
||||
}
|
||||
|
||||
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise<void> {
|
||||
const core = getMattermostRuntime();
|
||||
const runtime = resolveRuntime(opts);
|
||||
const cfg = opts.config ?? core.config.loadConfig();
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const botToken = opts.botToken?.trim() || account.botToken?.trim();
|
||||
if (!botToken) {
|
||||
throw new Error(
|
||||
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken });
|
||||
const botUser = await fetchMattermostMe(client);
|
||||
const botUserId = botUser.id;
|
||||
const botUsername = botUser.username?.trim() || undefined;
|
||||
runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`);
|
||||
|
||||
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) return;
|
||||
logger.debug?.(message);
|
||||
};
|
||||
const mediaMaxBytes =
|
||||
resolveChannelMediaMaxBytes({
|
||||
cfg,
|
||||
resolveChannelLimitMb: () => undefined,
|
||||
accountId: account.accountId,
|
||||
}) ?? 8 * 1024 * 1024;
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
const channelHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
const fetchWithAuth: FetchLike = (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${client.token}`);
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
|
||||
const resolveMattermostMedia = async (
|
||||
fileIds?: string[] | null,
|
||||
): Promise<MattermostMediaInfo[]> => {
|
||||
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean) as string[];
|
||||
if (ids.length === 0) return [];
|
||||
const out: MattermostMediaInfo[] = [];
|
||||
for (const fileId of ids) {
|
||||
try {
|
||||
const fetched = await core.channel.media.fetchRemoteMedia({
|
||||
url: `${client.apiBaseUrl}/files/${fileId}`,
|
||||
fetchImpl: fetchWithAuth,
|
||||
filePathHint: fileId,
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType ?? undefined,
|
||||
"inbound",
|
||||
mediaMaxBytes,
|
||||
);
|
||||
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType,
|
||||
kind: core.media.mediaKindFromMime(contentType),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
|
||||
try {
|
||||
await sendMattermostTyping(client, { channelId, parentId });
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
||||
const cached = channelCache.get(channelId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
try {
|
||||
const info = await fetchMattermostChannel(client, channelId);
|
||||
channelCache.set(channelId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
|
||||
channelCache.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
||||
const cached = userCache.get(userId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
try {
|
||||
const info = await fetchMattermostUser(client, userId);
|
||||
userCache.set(userId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
|
||||
userCache.set(userId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePost = async (
|
||||
post: MattermostPost,
|
||||
payload: MattermostEventPayload,
|
||||
messageIds?: string[],
|
||||
) => {
|
||||
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
|
||||
if (!channelId) return;
|
||||
|
||||
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
|
||||
if (allMessageIds.length === 0) return;
|
||||
const dedupeEntries = allMessageIds.map((id) =>
|
||||
recentInboundMessages.check(`${account.accountId}:${id}`),
|
||||
);
|
||||
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) return;
|
||||
|
||||
const senderId = post.user_id ?? payload.broadcast?.user_id;
|
||||
if (!senderId) return;
|
||||
if (senderId === botUserId) return;
|
||||
if (isSystemPost(post)) return;
|
||||
|
||||
const channelInfo = await resolveChannelInfo(channelId);
|
||||
const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
|
||||
const kind = channelKind(channelType);
|
||||
const chatType = channelChatType(kind);
|
||||
|
||||
const senderName =
|
||||
payload.data?.sender_name?.trim() ||
|
||||
(await resolveUserInfo(senderId))?.username?.trim() ||
|
||||
senderId;
|
||||
const rawText = post.message?.trim() || "";
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
|
||||
const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
|
||||
const storeAllowFrom = normalizeAllowList(
|
||||
await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
|
||||
);
|
||||
const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
|
||||
const effectiveGroupAllowFrom = Array.from(
|
||||
new Set([
|
||||
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
||||
...storeAllowFrom,
|
||||
]),
|
||||
);
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "mattermost",
|
||||
});
|
||||
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: effectiveAllowFrom,
|
||||
});
|
||||
const groupAllowedForCommands = isSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
});
|
||||
const commandAuthorized =
|
||||
kind === "dm"
|
||||
? dmPolicy === "open" || senderAllowedForCommands
|
||||
: core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
{
|
||||
configured: effectiveGroupAllowFrom.length > 0,
|
||||
allowed: groupAllowedForCommands,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (kind === "dm") {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`);
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open" && !senderAllowedForCommands) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "mattermost",
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
logVerboseMessage(
|
||||
`mattermost: pairing request sender=${senderId} created=${created}`,
|
||||
);
|
||||
if (created) {
|
||||
try {
|
||||
await sendMessageMattermost(
|
||||
`user:${senderId}`,
|
||||
core.channel.pairing.buildPairingReply({
|
||||
channel: "mattermost",
|
||||
idLine: `Your Mattermost user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
{ accountId: account.accountId },
|
||||
);
|
||||
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerboseMessage(
|
||||
`mattermost: pairing reply failed for ${senderId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerboseMessage(
|
||||
`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)");
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
logVerboseMessage("mattermost: drop group message (no group allowlist)");
|
||||
return;
|
||||
}
|
||||
if (!groupAllowedForCommands) {
|
||||
logVerboseMessage(
|
||||
`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kind !== "dm" && isControlCommand && !commandAuthorized) {
|
||||
logVerboseMessage(
|
||||
`mattermost: drop control command from unauthorized sender ${senderId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
|
||||
const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
|
||||
const channelDisplay =
|
||||
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
|
||||
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
teamId,
|
||||
peer: {
|
||||
kind,
|
||||
id: kind === "dm" ? senderId : channelId,
|
||||
},
|
||||
});
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const threadRootId = post.root_id?.trim() || undefined;
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey,
|
||||
threadId: threadRootId,
|
||||
parentSessionKey: threadRootId ? baseSessionKey : undefined,
|
||||
});
|
||||
const sessionKey = threadKeys.sessionKey;
|
||||
const historyKey = kind === "dm" ? null : sessionKey;
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
||||
const wasMentioned =
|
||||
kind !== "dm" &&
|
||||
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
|
||||
core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes));
|
||||
const pendingBody =
|
||||
rawText ||
|
||||
(post.file_ids?.length
|
||||
? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
|
||||
: "");
|
||||
const pendingSender = senderName;
|
||||
const recordPendingHistory = () => {
|
||||
if (!historyKey || historyLimit <= 0) return;
|
||||
const trimmed = pendingBody.trim();
|
||||
if (!trimmed) return;
|
||||
recordPendingHistoryEntry({
|
||||
historyMap: channelHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: pendingSender,
|
||||
body: trimmed,
|
||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
messageId: post.id ?? undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
|
||||
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
|
||||
const oncharResult = oncharEnabled
|
||||
? stripOncharPrefix(rawText, oncharPrefixes)
|
||||
: { triggered: false, stripped: rawText };
|
||||
const oncharTriggered = oncharResult.triggered;
|
||||
|
||||
const shouldRequireMention =
|
||||
kind !== "dm" &&
|
||||
core.channel.groups.resolveRequireMention({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
groupId: channelId,
|
||||
}) !== false;
|
||||
const shouldBypassMention =
|
||||
isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
|
||||
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
||||
|
||||
if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) {
|
||||
recordPendingHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind !== "dm" && shouldRequireMention && canDetectMention) {
|
||||
if (!effectiveWasMentioned) {
|
||||
recordPendingHistory();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const mediaList = await resolveMattermostMedia(post.file_ids);
|
||||
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
|
||||
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
|
||||
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
|
||||
const bodyText = normalizeMention(baseText, botUsername);
|
||||
if (!bodyText) return;
|
||||
|
||||
core.channel.activity.record({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
const fromLabel = formatInboundFromLabel({
|
||||
isGroup: kind !== "dm",
|
||||
groupLabel: channelDisplay || roomLabel,
|
||||
groupId: channelId,
|
||||
groupFallback: roomLabel || "Channel",
|
||||
directLabel: senderName,
|
||||
directId: senderId,
|
||||
});
|
||||
|
||||
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel =
|
||||
kind === "dm"
|
||||
? `Mattermost DM from ${senderName}`
|
||||
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
||||
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey,
|
||||
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
|
||||
});
|
||||
|
||||
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
||||
const body = core.channel.reply.formatInboundEnvelope({
|
||||
channel: "Mattermost",
|
||||
from: fromLabel,
|
||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
body: textWithId,
|
||||
chatType,
|
||||
sender: { name: senderName, id: senderId },
|
||||
});
|
||||
let combinedBody = body;
|
||||
if (historyKey && historyLimit > 0) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: channelHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatInboundEnvelope({
|
||||
channel: "Mattermost",
|
||||
from: fromLabel,
|
||||
timestamp: entry.timestamp,
|
||||
body: `${entry.body}${
|
||||
entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : ""
|
||||
}`,
|
||||
chatType,
|
||||
senderLabel: entry.sender,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
|
||||
const mediaPayload = buildMattermostMediaPayload(mediaList);
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
From:
|
||||
kind === "dm"
|
||||
? `mattermost:${senderId}`
|
||||
: kind === "group"
|
||||
? `mattermost:group:${channelId}`
|
||||
: `mattermost:channel:${channelId}`,
|
||||
To: to,
|
||||
SessionKey: sessionKey,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: chatType,
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: kind !== "dm" ? channelDisplay || roomLabel : undefined,
|
||||
GroupChannel: channelName ? `#${channelName}` : undefined,
|
||||
GroupSpace: teamId,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
Provider: "mattermost" as const,
|
||||
Surface: "mattermost" as const,
|
||||
MessageSid: post.id ?? undefined,
|
||||
MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined,
|
||||
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
|
||||
MessageSidLast:
|
||||
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
|
||||
ReplyToId: threadRootId,
|
||||
MessageThreadId: threadRootId,
|
||||
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
WasMentioned: kind !== "dm" ? effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
OriginatingChannel: "mattermost" as const,
|
||||
OriginatingTo: to,
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
if (kind === "dm") {
|
||||
const sessionCfg = cfg.session;
|
||||
const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
await core.channel.session.updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
deliveryContext: {
|
||||
channel: "mattermost",
|
||||
to,
|
||||
accountId: route.accountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(
|
||||
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
||||
);
|
||||
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
||||
fallbackLimit: account.textChunkLimit ?? 4000,
|
||||
});
|
||||
|
||||
let prefixContext: ResponsePrefixContext = {
|
||||
identityName: resolveIdentityName(cfg, route.agentId),
|
||||
};
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||
.responsePrefix,
|
||||
responsePrefixContextProvider: () => prefixContext,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
if (mediaUrls.length === 0) {
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||
if (!chunk) continue;
|
||||
await sendMessageMattermost(to, chunk, {
|
||||
accountId: account.accountId,
|
||||
replyToId: threadRootId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaUrls) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
await sendMessageMattermost(to, caption, {
|
||||
accountId: account.accountId,
|
||||
mediaUrl,
|
||||
replyToId: threadRootId,
|
||||
});
|
||||
}
|
||||
}
|
||||
runtime.log?.(`delivered reply to ${to}`);
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected: (ctx) => {
|
||||
prefixContext.provider = ctx.provider;
|
||||
prefixContext.model = extractShortModelName(ctx.model);
|
||||
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
|
||||
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
|
||||
},
|
||||
},
|
||||
});
|
||||
markDispatchIdle();
|
||||
if (historyKey && historyLimit > 0) {
|
||||
clearHistoryEntries({ historyMap: channelHistories, historyKey });
|
||||
}
|
||||
};
|
||||
|
||||
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
});
|
||||
const debouncer = core.channel.debounce.createInboundDebouncer<{
|
||||
post: MattermostPost;
|
||||
payload: MattermostEventPayload;
|
||||
}>({
|
||||
debounceMs: inboundDebounceMs,
|
||||
buildKey: (entry) => {
|
||||
const channelId =
|
||||
entry.post.channel_id ??
|
||||
entry.payload.data?.channel_id ??
|
||||
entry.payload.broadcast?.channel_id;
|
||||
if (!channelId) return null;
|
||||
const threadId = entry.post.root_id?.trim();
|
||||
const threadKey = threadId ? `thread:${threadId}` : "channel";
|
||||
return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
if (entry.post.file_ids && entry.post.file_ids.length > 0) return false;
|
||||
const text = entry.post.message?.trim() ?? "";
|
||||
if (!text) return false;
|
||||
return !core.channel.text.hasControlCommand(text, cfg);
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) return;
|
||||
if (entries.length === 1) {
|
||||
await handlePost(last.post, last.payload);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.post.message?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const mergedPost: MattermostPost = {
|
||||
...last.post,
|
||||
message: combinedText,
|
||||
file_ids: [],
|
||||
};
|
||||
const ids = entries.map((entry) => entry.post.id).filter(Boolean) as string[];
|
||||
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(`mattermost debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const wsUrl = buildMattermostWsUrl(baseUrl);
|
||||
let seq = 1;
|
||||
|
||||
const connectOnce = async (): Promise<void> => {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const onAbort = () => ws.close();
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
ws.on("open", () => {
|
||||
opts.statusSink?.({
|
||||
connected: true,
|
||||
lastConnectedAt: Date.now(),
|
||||
lastError: null,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
seq: seq++,
|
||||
action: "authentication_challenge",
|
||||
data: { token: botToken },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
const raw = rawDataToString(data);
|
||||
let payload: MattermostEventPayload;
|
||||
try {
|
||||
payload = JSON.parse(raw) as MattermostEventPayload;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (payload.event !== "posted") return;
|
||||
const postData = payload.data?.post;
|
||||
if (!postData) return;
|
||||
let post: MattermostPost | null = null;
|
||||
if (typeof postData === "string") {
|
||||
try {
|
||||
post = JSON.parse(postData) as MattermostPost;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
} else if (typeof postData === "object") {
|
||||
post = postData as MattermostPost;
|
||||
}
|
||||
if (!post) return;
|
||||
try {
|
||||
await debouncer.enqueue({ post, payload });
|
||||
} catch (err) {
|
||||
runtime.error?.(`mattermost handler failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", (code, reason) => {
|
||||
const message = reason.length > 0 ? reason.toString("utf8") : "";
|
||||
opts.statusSink?.({
|
||||
connected: false,
|
||||
lastDisconnect: {
|
||||
at: Date.now(),
|
||||
status: code,
|
||||
error: message || undefined,
|
||||
},
|
||||
});
|
||||
opts.abortSignal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
runtime.error?.(`mattermost websocket error: ${String(err)}`);
|
||||
opts.statusSink?.({
|
||||
lastError: String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
while (!opts.abortSignal?.aborted) {
|
||||
await connectOnce();
|
||||
if (opts.abortSignal?.aborted) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js";
|
||||
|
||||
export type MattermostProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: MattermostUser;
|
||||
};
|
||||
|
||||
async function readMattermostError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await res.json()) as { message?: string } | undefined;
|
||||
if (data?.message) return data.message;
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export async function probeMattermost(
|
||||
baseUrl: string,
|
||||
botToken: string,
|
||||
timeoutMs = 2500,
|
||||
): Promise<MattermostProbe> {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "baseUrl missing" };
|
||||
}
|
||||
const url = `${normalized}/api/v4/users/me`;
|
||||
const start = Date.now();
|
||||
const controller = timeoutMs > 0 ? new AbortController() : undefined;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (controller) {
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
signal: controller?.signal,
|
||||
});
|
||||
const elapsedMs = Date.now() - start;
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
error: detail || res.statusText,
|
||||
elapsedMs,
|
||||
};
|
||||
}
|
||||
const bot = (await res.json()) as MattermostUser;
|
||||
return {
|
||||
ok: true,
|
||||
status: res.status,
|
||||
elapsedMs,
|
||||
bot,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: message,
|
||||
elapsedMs: Date.now() - start,
|
||||
};
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
createMattermostDirectChannel,
|
||||
createMattermostPost,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUserByUsername,
|
||||
normalizeMattermostBaseUrl,
|
||||
uploadMattermostFile,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
};
|
||||
|
||||
export type MattermostSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
type MattermostTarget =
|
||||
| { kind: "channel"; id: string }
|
||||
| { kind: "user"; id?: string; username?: string };
|
||||
|
||||
const botUserCache = new Map<string, MattermostUser>();
|
||||
const userByNameCache = new Map<string, MattermostUser>();
|
||||
|
||||
const getCore = () => getMattermostRuntime();
|
||||
|
||||
function cacheKey(baseUrl: string, token: string): string {
|
||||
return `${baseUrl}::${token}`;
|
||||
}
|
||||
|
||||
function normalizeMessage(text: string, mediaUrl?: string): string {
|
||||
const trimmed = text.trim();
|
||||
const media = mediaUrl?.trim();
|
||||
return [trimmed, media].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function parseMattermostTarget(raw: string): MattermostTarget {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) throw new Error("Recipient is required for Mattermost sends");
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
if (!id) throw new Error("Channel id is required for Mattermost sends");
|
||||
return { kind: "channel", id };
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
if (!id) throw new Error("User id is required for Mattermost sends");
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
if (!id) throw new Error("User id is required for Mattermost sends");
|
||||
return { kind: "user", id };
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const username = trimmed.slice(1).trim();
|
||||
if (!username) {
|
||||
throw new Error("Username is required for Mattermost sends");
|
||||
}
|
||||
return { kind: "user", username };
|
||||
}
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> {
|
||||
const key = cacheKey(baseUrl, token);
|
||||
const cached = botUserCache.get(key);
|
||||
if (cached) return cached;
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
const user = await fetchMattermostMe(client);
|
||||
botUserCache.set(key, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function resolveUserIdByUsername(params: {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
username: string;
|
||||
}): Promise<string> {
|
||||
const { baseUrl, token, username } = params;
|
||||
const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`;
|
||||
const cached = userByNameCache.get(key);
|
||||
if (cached?.id) return cached.id;
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
const user = await fetchMattermostUserByUsername(client, username);
|
||||
userByNameCache.set(key, user);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function resolveTargetChannelId(params: {
|
||||
target: MattermostTarget;
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
}): Promise<string> {
|
||||
if (params.target.kind === "channel") return params.target.id;
|
||||
const userId = params.target.id
|
||||
? params.target.id
|
||||
: await resolveUserIdByUsername({
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
username: params.target.username ?? "",
|
||||
});
|
||||
const botUser = await resolveBotUser(params.baseUrl, params.token);
|
||||
const client = createMattermostClient({
|
||||
baseUrl: params.baseUrl,
|
||||
botToken: params.token,
|
||||
});
|
||||
const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
|
||||
return channel.id;
|
||||
}
|
||||
|
||||
export async function sendMessageMattermost(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
): Promise<MattermostSendResult> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const cfg = core.config.loadConfig();
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = opts.botToken?.trim() || account.botToken?.trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
||||
);
|
||||
}
|
||||
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
const target = parseMattermostTarget(to);
|
||||
const channelId = await resolveTargetChannelId({
|
||||
target,
|
||||
baseUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||
let message = text?.trim() ?? "";
|
||||
let fileIds: string[] | undefined;
|
||||
let uploadError: Error | undefined;
|
||||
const mediaUrl = opts.mediaUrl?.trim();
|
||||
if (mediaUrl) {
|
||||
try {
|
||||
const media = await core.media.loadWebMedia(mediaUrl);
|
||||
const fileInfo = await uploadMattermostFile(client, {
|
||||
channelId,
|
||||
buffer: media.buffer,
|
||||
fileName: media.fileName ?? "upload",
|
||||
contentType: media.contentType ?? undefined,
|
||||
});
|
||||
fileIds = [fileInfo.id];
|
||||
} catch (err) {
|
||||
uploadError = err instanceof Error ? err : new Error(String(err));
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug?.(
|
||||
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : "");
|
||||
}
|
||||
}
|
||||
|
||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
||||
if (uploadError) {
|
||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
||||
}
|
||||
throw new Error("Mattermost message is empty");
|
||||
}
|
||||
|
||||
const post = await createMattermostPost(client, {
|
||||
channelId,
|
||||
message,
|
||||
rootId: opts.replyToId,
|
||||
fileIds,
|
||||
});
|
||||
|
||||
core.channel.activity.record({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: post.id ?? "unknown",
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.startsWith("channel:")) {
|
||||
const id = trimmed.slice("channel:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("group:")) {
|
||||
const id = trimmed.slice("group:".length).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("mattermost:")) {
|
||||
const id = trimmed.slice("mattermost:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `@${id}` : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `channel:${id}` : undefined;
|
||||
}
|
||||
return `channel:${trimmed}`;
|
||||
}
|
||||
|
||||
export function looksLikeMattermostTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(user|channel|group|mattermost):/i.test(trimmed)) return true;
|
||||
if (/^[@#]/.test(trimmed)) return true;
|
||||
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
type PromptAccountIdParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
currentId?: string;
|
||||
listAccountIds: (cfg: ClawdbotConfig) => string[];
|
||||
defaultAccountId: string;
|
||||
};
|
||||
|
||||
export async function promptAccountId(params: PromptAccountIdParams): Promise<string> {
|
||||
const existingIds = params.listAccountIds(params.cfg);
|
||||
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
|
||||
const choice = (await params.prompter.select({
|
||||
message: `${params.label} account`,
|
||||
options: [
|
||||
...existingIds.map((id) => ({
|
||||
value: id,
|
||||
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
||||
})),
|
||||
{ value: "__new__", label: "Add a new account" },
|
||||
],
|
||||
initialValue: initial,
|
||||
})) as string;
|
||||
|
||||
if (choice !== "__new__") return normalizeAccountId(choice);
|
||||
|
||||
const entered = await params.prompter.text({
|
||||
message: `New ${params.label} account id`,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
const normalized = normalizeAccountId(String(entered));
|
||||
if (String(entered).trim() !== normalized) {
|
||||
await params.prompter.note(
|
||||
`Normalized account id to "${normalized}".`,
|
||||
`${params.label} account`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import type { ChannelOnboardingAdapter, ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
import { promptAccountId } from "./onboarding-helpers.js";
|
||||
|
||||
const channel = "mattermost" as const;
|
||||
|
||||
async function noteMattermostSetup(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Mattermost System Console -> Integrations -> Bot Accounts",
|
||||
"2) Create a bot + copy its token",
|
||||
"3) Use your server base URL (e.g., https://chat.example.com)",
|
||||
"Tip: the bot must be a member of any channel you want it to monitor.",
|
||||
"Docs: https://docs.clawd.bot/channels/mattermost",
|
||||
].join("\n"),
|
||||
"Mattermost bot token",
|
||||
);
|
||||
}
|
||||
|
||||
export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listMattermostAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveMattermostAccount({ cfg, accountId });
|
||||
return Boolean(account.botToken && account.baseUrl);
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Mattermost: ${configured ? "configured" : "needs token + url"}`],
|
||||
selectionHint: configured ? "configured" : "needs setup",
|
||||
quickstartScore: configured ? 2 : 1,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
const override = accountOverrides.mattermost?.trim();
|
||||
const defaultAccountId = resolveDefaultMattermostAccountId(cfg);
|
||||
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
||||
if (shouldPromptAccountIds && !override) {
|
||||
accountId = await promptAccountId({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Mattermost",
|
||||
currentId: accountId,
|
||||
listAccountIds: listMattermostAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveMattermostAccount({
|
||||
cfg: next,
|
||||
accountId,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl);
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv =
|
||||
allowEnv &&
|
||||
Boolean(process.env.MATTERMOST_BOT_TOKEN?.trim()) &&
|
||||
Boolean(process.env.MATTERMOST_URL?.trim());
|
||||
const hasConfigValues =
|
||||
Boolean(resolvedAccount.config.botToken) || Boolean(resolvedAccount.config.baseUrl);
|
||||
|
||||
let botToken: string | null = null;
|
||||
let baseUrl: string | null = null;
|
||||
|
||||
if (!accountConfigured) {
|
||||
await noteMattermostSetup(prompter);
|
||||
}
|
||||
|
||||
if (canUseEnv && !hasConfigValues) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (accountConfigured) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Mattermost credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
botToken = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
baseUrl = String(
|
||||
await prompter.text({
|
||||
message: "Enter Mattermost base URL",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (botToken || baseUrl) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
...(botToken ? { botToken } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
mattermost: {
|
||||
...next.channels?.mattermost,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.mattermost?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.mattermost?.accounts?.[accountId],
|
||||
enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true,
|
||||
...(botToken ? { botToken } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
disable: (cfg: ClawdbotConfig) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMattermostRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMattermostRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Mattermost runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
||||
|
||||
export type MattermostAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||
capabilities?: string[];
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/** If false, do not start this Mattermost account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Bot token for Mattermost. */
|
||||
botToken?: string;
|
||||
/** Base URL for the Mattermost server (e.g., https://chat.example.com). */
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Controls when channel messages trigger replies.
|
||||
* - "oncall": only respond when mentioned
|
||||
* - "onmessage": respond to every channel message
|
||||
* - "onchar": respond when a trigger character prefixes the message
|
||||
*/
|
||||
chatmode?: MattermostChatMode;
|
||||
/** Prefix characters that trigger onchar mode (default: [">", "!"]). */
|
||||
oncharPrefixes?: string[];
|
||||
/** Require @mention to respond in channels. Default: true. */
|
||||
requireMention?: boolean;
|
||||
/** Direct message policy (pairing/allowlist/open/disabled). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/** Allowlist for direct messages (user ids or @usernames). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Allowlist for group messages (user ids or @usernames). */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/** Group message policy (allowlist/open/disabled). */
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
};
|
||||
|
||||
export type MattermostConfig = {
|
||||
/** Optional per-account Mattermost configuration (multi-account). */
|
||||
accounts?: Record<string, MattermostAccountConfig>;
|
||||
} & MattermostAccountConfig;
|
||||
Generated
-2
@@ -321,8 +321,6 @@ importers:
|
||||
specifier: ^11.10.6
|
||||
version: 11.10.6
|
||||
|
||||
extensions/mattermost: {}
|
||||
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
|
||||
+5
-18
@@ -20,7 +20,6 @@ SIGN=0
|
||||
AUTO_DETECT_SIGNING=1
|
||||
GATEWAY_WAIT_SECONDS="${CLAWDBOT_GATEWAY_WAIT_SECONDS:-0}"
|
||||
LAUNCHAGENT_DISABLE_MARKER="${HOME}/.clawdbot/disable-launchagent"
|
||||
ATTACH_ONLY=1
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -82,15 +81,11 @@ for arg in "$@"; do
|
||||
--wait|-w) WAIT_FOR_LOCK=1 ;;
|
||||
--no-sign) NO_SIGN=1; AUTO_DETECT_SIGNING=0 ;;
|
||||
--sign) SIGN=1; AUTO_DETECT_SIGNING=0 ;;
|
||||
--attach-only) ATTACH_ONLY=1 ;;
|
||||
--no-attach-only) ATTACH_ONLY=0 ;;
|
||||
--help|-h)
|
||||
log "Usage: $(basename "$0") [--wait] [--no-sign] [--sign] [--attach-only|--no-attach-only]"
|
||||
log "Usage: $(basename "$0") [--wait] [--no-sign] [--sign]"
|
||||
log " --wait Wait for other restart to complete instead of exiting"
|
||||
log " --no-sign Force no code signing (fastest for development)"
|
||||
log " --sign Force code signing (will fail if no signing key available)"
|
||||
log " --attach-only Launch app with --attach-only (skip launchd install)"
|
||||
log " --no-attach-only Launch app without attach-only override"
|
||||
log ""
|
||||
log "Env:"
|
||||
log " CLAWDBOT_GATEWAY_WAIT_SECONDS=0 Wait time before gateway port check (unsigned only)"
|
||||
@@ -120,9 +115,6 @@ log "==> Log: ${LOG_PATH}"
|
||||
if [[ "$NO_SIGN" -eq 1 ]]; then
|
||||
log "==> Using --no-sign (unsigned flow enabled)"
|
||||
fi
|
||||
if [[ "$ATTACH_ONLY" -eq 1 ]]; then
|
||||
log "==> Using --attach-only (skip launchd install)"
|
||||
fi
|
||||
|
||||
acquire_lock
|
||||
|
||||
@@ -210,13 +202,13 @@ choose_app_bundle() {
|
||||
choose_app_bundle
|
||||
|
||||
# When signed, clear any previous launchagent override marker.
|
||||
if [[ "$NO_SIGN" -ne 1 && "$ATTACH_ONLY" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then
|
||||
if [[ "$NO_SIGN" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then
|
||||
run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}"
|
||||
fi
|
||||
|
||||
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (before the app launches).
|
||||
# This reduces noisy "could not connect" errors during app startup.
|
||||
if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then
|
||||
if [ "$NO_SIGN" -eq 1 ]; then
|
||||
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
|
||||
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
|
||||
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
|
||||
@@ -239,11 +231,6 @@ if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then
|
||||
run_step "verify gateway port ${GATEWAY_PORT} (unsigned)" bash -lc "lsof -iTCP:${GATEWAY_PORT} -sTCP:LISTEN | head -n 5 || true"
|
||||
fi
|
||||
|
||||
ATTACH_ONLY_ARGS=()
|
||||
if [[ "$ATTACH_ONLY" -eq 1 ]]; then
|
||||
ATTACH_ONLY_ARGS+=(--args --attach-only)
|
||||
fi
|
||||
|
||||
# 4) Launch the installed app in the foreground so the menu bar extra appears.
|
||||
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
|
||||
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
|
||||
@@ -254,7 +241,7 @@ run_step "launch app" env -i \
|
||||
TMPDIR="${TMPDIR:-/tmp}" \
|
||||
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
|
||||
LANG="${LANG:-en_US.UTF-8}" \
|
||||
/usr/bin/open "${APP_BUNDLE}" ${ATTACH_ONLY_ARGS[@]:+"${ATTACH_ONLY_ARGS[@]}"}
|
||||
/usr/bin/open "${APP_BUNDLE}"
|
||||
|
||||
# 5) Verify the app is alive.
|
||||
sleep 1.5
|
||||
@@ -264,6 +251,6 @@ else
|
||||
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
||||
fi
|
||||
|
||||
if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then
|
||||
if [ "$NO_SIGN" -eq 1 ]; then
|
||||
run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true"
|
||||
fi
|
||||
@@ -17,7 +17,7 @@ docker build \
|
||||
"$ROOT_DIR/scripts/docker/install-sh-e2e"
|
||||
|
||||
echo "==> Run E2E installer test"
|
||||
docker run --rm \
|
||||
docker run --rm -t \
|
||||
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
||||
-e CLAWDBOT_INSTALL_TAG="${CLAWDBOT_INSTALL_TAG:-latest}" \
|
||||
-e CLAWDBOT_E2E_MODELS="$CLAWDBOT_E2E_MODELS" \
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
|
||||
|
||||
Setup (once)
|
||||
- `gog auth credentials /path/to/client_secret.json`
|
||||
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets`
|
||||
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,sheets,docs`
|
||||
- `gog auth list`
|
||||
|
||||
Common commands
|
||||
|
||||
@@ -16,15 +16,11 @@ vi.mock("./tools/nodes-utils.js", () => ({
|
||||
|
||||
describe("exec approvals", () => {
|
||||
let previousHome: string | undefined;
|
||||
let previousUserProfile: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousHome = process.env.HOME;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
||||
process.env.HOME = tempDir;
|
||||
// Windows uses USERPROFILE for os.homedir()
|
||||
process.env.USERPROFILE = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -34,11 +30,6 @@ describe("exec approvals", () => {
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previousUserProfile;
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses approval id as the node runId", async () => {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
type ExecSecurity,
|
||||
type ExecApprovalsFile,
|
||||
addAllowlistEntry,
|
||||
evaluateShellAllowlist,
|
||||
analyzeShellCommand,
|
||||
evaluateExecAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
requiresExecApproval,
|
||||
@@ -870,16 +871,9 @@ export function createExecTool(
|
||||
if (nodeEnv) {
|
||||
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
|
||||
}
|
||||
const baseAllowlistEval = evaluateShellAllowlist({
|
||||
command: params.command,
|
||||
allowlist: [],
|
||||
safeBins: new Set(),
|
||||
cwd: workdir,
|
||||
env,
|
||||
});
|
||||
let analysisOk = baseAllowlistEval.analysisOk;
|
||||
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
|
||||
let allowlistSatisfied = false;
|
||||
if (hostAsk === "on-miss" && hostSecurity === "allowlist" && analysisOk) {
|
||||
if (hostAsk === "on-miss" && hostSecurity === "allowlist") {
|
||||
try {
|
||||
const approvalsSnapshot = (await callGatewayTool(
|
||||
"exec.approvals.node.get",
|
||||
@@ -897,15 +891,12 @@ export function createExecTool(
|
||||
overrides: { security: "allowlist" },
|
||||
});
|
||||
// Allowlist-only precheck; safe bins are node-local and may diverge.
|
||||
const allowlistEval = evaluateShellAllowlist({
|
||||
command: params.command,
|
||||
allowlistSatisfied = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: resolved.allowlist,
|
||||
safeBins: new Set(),
|
||||
cwd: workdir,
|
||||
env,
|
||||
});
|
||||
allowlistSatisfied = allowlistEval.allowlistSatisfied;
|
||||
analysisOk = allowlistEval.analysisOk;
|
||||
}).allowlistSatisfied;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to requiring approval if node approvals cannot be fetched.
|
||||
@@ -914,7 +905,7 @@ export function createExecTool(
|
||||
const requiresAsk = requiresExecApproval({
|
||||
ask: hostAsk,
|
||||
security: hostSecurity,
|
||||
analysisOk,
|
||||
analysisOk: analysis.ok,
|
||||
allowlistSatisfied,
|
||||
});
|
||||
const commandText = params.command;
|
||||
@@ -1104,21 +1095,20 @@ export function createExecTool(
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error("exec denied: host=gateway security=deny");
|
||||
}
|
||||
const allowlistEval = evaluateShellAllowlist({
|
||||
command: params.command,
|
||||
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
|
||||
const allowlistEval = evaluateExecAllowlist({
|
||||
analysis,
|
||||
allowlist: approvals.allowlist,
|
||||
safeBins,
|
||||
cwd: workdir,
|
||||
env,
|
||||
});
|
||||
const allowlistMatches = allowlistEval.allowlistMatches;
|
||||
const analysisOk = allowlistEval.analysisOk;
|
||||
const allowlistSatisfied =
|
||||
hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
||||
hostSecurity === "allowlist" && analysis.ok ? allowlistEval.allowlistSatisfied : false;
|
||||
const requiresAsk = requiresExecApproval({
|
||||
ask: hostAsk,
|
||||
security: hostSecurity,
|
||||
analysisOk,
|
||||
analysisOk: analysis.ok,
|
||||
allowlistSatisfied,
|
||||
});
|
||||
|
||||
@@ -1127,7 +1117,7 @@ export function createExecTool(
|
||||
const approvalSlug = createApprovalSlug(approvalId);
|
||||
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
const contextKey = `exec:${approvalId}`;
|
||||
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
|
||||
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
|
||||
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
|
||||
const commandText = params.command;
|
||||
const effectiveTimeout =
|
||||
@@ -1174,7 +1164,7 @@ export function createExecTool(
|
||||
if (askFallback === "full") {
|
||||
approvedByAsk = true;
|
||||
} else if (askFallback === "allowlist") {
|
||||
if (!analysisOk || !allowlistSatisfied) {
|
||||
if (!analysis.ok || !allowlistSatisfied) {
|
||||
deniedReason = "approval-timeout (allowlist-miss)";
|
||||
} else {
|
||||
approvedByAsk = true;
|
||||
@@ -1187,7 +1177,7 @@ export function createExecTool(
|
||||
} else if (decision === "allow-always") {
|
||||
approvedByAsk = true;
|
||||
if (hostSecurity === "allowlist") {
|
||||
for (const segment of allowlistEval.segments) {
|
||||
for (const segment of analysis.segments) {
|
||||
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
@@ -1198,7 +1188,7 @@ export function createExecTool(
|
||||
|
||||
if (
|
||||
hostSecurity === "allowlist" &&
|
||||
(!analysisOk || !allowlistSatisfied) &&
|
||||
(!analysis.ok || !allowlistSatisfied) &&
|
||||
!approvedByAsk
|
||||
) {
|
||||
deniedReason = deniedReason ?? "allowlist-miss";
|
||||
@@ -1298,7 +1288,7 @@ export function createExecTool(
|
||||
};
|
||||
}
|
||||
|
||||
if (hostSecurity === "allowlist" && (!analysisOk || !allowlistSatisfied)) {
|
||||
if (hostSecurity === "allowlist" && (!analysis.ok || !allowlistSatisfied)) {
|
||||
throw new Error("exec denied: allowlist miss");
|
||||
}
|
||||
|
||||
@@ -1312,7 +1302,7 @@ export function createExecTool(
|
||||
agentId,
|
||||
match,
|
||||
params.command,
|
||||
allowlistEval.segments[0]?.resolution?.resolvedPath,
|
||||
analysis.segments[0]?.resolution?.resolvedPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ describe("formatAssistantErrorText", () => {
|
||||
const msg = makeAssistantError(
|
||||
'{"type":"error","error":{"message":"Something exploded","type":"server_error"}}',
|
||||
);
|
||||
expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded");
|
||||
expect(formatAssistantErrorText(msg)).toBe(
|
||||
"The AI service returned an error. Please try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,4 @@ describe("formatRawAssistantErrorForUi", () => {
|
||||
it("renders a generic unknown error message when raw is empty", () => {
|
||||
expect(formatRawAssistantErrorForUi("")).toContain("unknown error");
|
||||
});
|
||||
|
||||
it("formats plain HTTP status lines", () => {
|
||||
expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe(
|
||||
"HTTP 500: Internal Server Error",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -19,12 +19,12 @@ describe("sanitizeUserFacingText", () => {
|
||||
|
||||
it("sanitizes HTTP status errors with error hints", () => {
|
||||
expect(sanitizeUserFacingText("500 Internal Server Error")).toBe(
|
||||
"HTTP 500: Internal Server Error",
|
||||
"The AI service returned an error. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes raw API error payloads", () => {
|
||||
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
|
||||
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
|
||||
expect(sanitizeUserFacingText(raw)).toBe("The AI service returned an error. Please try again.");
|
||||
});
|
||||
});
|
||||
@@ -201,14 +201,6 @@ export function formatRawAssistantErrorForUi(raw?: string): string {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return "LLM request failed with an unknown error.";
|
||||
|
||||
const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
|
||||
if (httpMatch) {
|
||||
const rest = httpMatch[2].trim();
|
||||
if (!rest.startsWith("{")) {
|
||||
return `HTTP ${httpMatch[1]}: ${rest}`;
|
||||
}
|
||||
}
|
||||
|
||||
const info = parseApiErrorInfo(trimmed);
|
||||
if (info?.message) {
|
||||
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
|
||||
@@ -269,8 +261,8 @@ export function formatAssistantErrorText(
|
||||
return "The AI service is temporarily overloaded. Please try again in a moment.";
|
||||
}
|
||||
|
||||
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
|
||||
return formatRawAssistantErrorForUi(raw);
|
||||
if (isRawApiErrorPayload(raw)) {
|
||||
return "The AI service returned an error. Please try again.";
|
||||
}
|
||||
|
||||
// Never return raw unhandled errors - log for debugging but return safe message
|
||||
@@ -301,7 +293,7 @@ export function sanitizeUserFacingText(text: string): string {
|
||||
}
|
||||
|
||||
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
return "The AI service returned an error. Please try again.";
|
||||
}
|
||||
|
||||
if (ERROR_PREFIX_RE.test(trimmed)) {
|
||||
@@ -311,7 +303,7 @@ export function sanitizeUserFacingText(text: string): string {
|
||||
if (isTimeoutErrorMessage(trimmed)) {
|
||||
return "LLM request timed out.";
|
||||
}
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
return "The AI service returned an error. Please try again.";
|
||||
}
|
||||
|
||||
return stripped;
|
||||
|
||||
@@ -320,7 +320,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
|
||||
});
|
||||
|
||||
it("sanitizes tool call ids for Google APIs", async () => {
|
||||
it("sanitizes tool call ids for OpenAI-compatible APIs", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const longId = `call_${"a".repeat(60)}`;
|
||||
const input = [
|
||||
@@ -338,21 +338,17 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "google-antigravity",
|
||||
modelApi: "openai-responses",
|
||||
sessionManager,
|
||||
sessionId: "session:google",
|
||||
sessionId: "session:openai",
|
||||
});
|
||||
|
||||
const assistant = out.find(
|
||||
(msg) => (msg as { role?: unknown }).role === "assistant",
|
||||
) as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const toolCall = assistant.content?.[0] as { id?: string };
|
||||
expect(toolCall.id).toBeDefined();
|
||||
expect(toolCall.id?.length).toBeLessThanOrEqual(40);
|
||||
|
||||
const toolResult = out.find(
|
||||
(msg) => (msg as { role?: unknown }).role === "toolResult",
|
||||
) as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const toolResult = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(toolResult.toolCallId).toBe(toolCall.id);
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not sanitize tool call ids for non-Google APIs", async () => {
|
||||
it("does not sanitize tool call ids for non-Google, non-OpenAI APIs", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
await sanitizeSessionHistory({
|
||||
@@ -92,25 +92,6 @@ describe("sanitizeSessionHistory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not sanitize tool call ids for openai-responses", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
await sanitizeSessionHistory({
|
||||
messages: mockMessages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("openai-responses");
|
||||
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||
mockMessages,
|
||||
"session:history",
|
||||
expect.objectContaining({ sanitizeToolCallIds: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps reasoning-only assistant messages for openai-responses", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
|
||||
import {
|
||||
createAgentSession,
|
||||
estimateTokens,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
|
||||
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
@@ -375,21 +370,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
session.agent.replaceMessages(limited);
|
||||
}
|
||||
const result = await session.compact(params.customInstructions);
|
||||
// Estimate tokens after compaction by summing token estimates for remaining messages
|
||||
let tokensAfter: number | undefined;
|
||||
try {
|
||||
tokensAfter = 0;
|
||||
for (const message of session.messages) {
|
||||
tokensAfter += estimateTokens(message);
|
||||
}
|
||||
// Sanity check: tokensAfter should be less than tokensBefore
|
||||
if (tokensAfter > result.tokensBefore) {
|
||||
tokensAfter = undefined; // Don't trust the estimate
|
||||
}
|
||||
} catch {
|
||||
// If estimation fails, leave tokensAfter undefined
|
||||
tokensAfter = undefined;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
@@ -397,7 +377,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
summary: result.summary,
|
||||
firstKeptEntryId: result.firstKeptEntryId,
|
||||
tokensBefore: result.tokensBefore,
|
||||
tokensAfter,
|
||||
details: result.details,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,6 +40,12 @@ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
const OPENAI_TOOL_CALL_ID_APIS = new Set([
|
||||
"openai",
|
||||
"openai-completions",
|
||||
"openai-responses",
|
||||
"openai-codex-responses",
|
||||
]);
|
||||
const MISTRAL_MODEL_HINTS = [
|
||||
"mistral",
|
||||
"mixtral",
|
||||
@@ -61,7 +67,7 @@ function isValidAntigravitySignature(value: unknown): value is string {
|
||||
|
||||
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
||||
if (!modelApi) return false;
|
||||
return isGoogleModelApi(modelApi);
|
||||
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
|
||||
}
|
||||
|
||||
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import {
|
||||
formatAssistantErrorText,
|
||||
formatRawAssistantErrorForUi,
|
||||
getApiErrorPayloadFingerprint,
|
||||
isRawApiErrorPayload,
|
||||
normalizeTextForComparison,
|
||||
@@ -65,12 +64,6 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
const rawErrorFingerprint = rawErrorMessage
|
||||
? getApiErrorPayloadFingerprint(rawErrorMessage)
|
||||
: null;
|
||||
const formattedRawErrorMessage = rawErrorMessage
|
||||
? formatRawAssistantErrorForUi(rawErrorMessage)
|
||||
: null;
|
||||
const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
|
||||
? normalizeTextForComparison(formattedRawErrorMessage)
|
||||
: null;
|
||||
const normalizedRawErrorText = rawErrorMessage
|
||||
? normalizeTextForComparison(rawErrorMessage)
|
||||
: null;
|
||||
@@ -123,15 +116,10 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
if (trimmed === genericErrorText) return true;
|
||||
}
|
||||
if (rawErrorMessage && trimmed === rawErrorMessage) return true;
|
||||
if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) return true;
|
||||
if (normalizedRawErrorText) {
|
||||
const normalized = normalizeTextForComparison(trimmed);
|
||||
if (normalized && normalized === normalizedRawErrorText) return true;
|
||||
}
|
||||
if (normalizedFormattedRawErrorMessage) {
|
||||
const normalized = normalizeTextForComparison(trimmed);
|
||||
if (normalized && normalized === normalizedFormattedRawErrorMessage) return true;
|
||||
}
|
||||
if (rawErrorFingerprint) {
|
||||
const fingerprint = getApiErrorPayloadFingerprint(trimmed);
|
||||
if (fingerprint && fingerprint === rawErrorFingerprint) return true;
|
||||
|
||||
@@ -59,7 +59,6 @@ export type EmbeddedPiCompactResult = {
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
tokensAfter?: number;
|
||||
details?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -212,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
sessions_send: "Send a message to another session/sub-agent",
|
||||
sessions_spawn: "Spawn a sub-agent session",
|
||||
session_status:
|
||||
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
|
||||
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override",
|
||||
image: "Analyze an image with the configured image model",
|
||||
};
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ export function createSessionStatusTool(opts?: {
|
||||
label: "Session Status",
|
||||
name: "session_status",
|
||||
description:
|
||||
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
|
||||
"Show a /status-equivalent session status card. Optional: set per-session model override (model=default resets overrides). Includes usage + cost when available.",
|
||||
parameters: SessionStatusToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
|
||||
@@ -83,13 +83,18 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
||||
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
|
||||
});
|
||||
|
||||
const totalTokens =
|
||||
params.sessionEntry.totalTokens ??
|
||||
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
|
||||
const contextSummary = formatContextUsageShort(
|
||||
totalTokens > 0 ? totalTokens : null,
|
||||
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
|
||||
);
|
||||
const compactLabel = result.ok
|
||||
? result.compacted
|
||||
? result.result?.tokensBefore != null && result.result?.tokensAfter != null
|
||||
? `Compacted (${formatTokenCount(result.result.tokensBefore)} → ${formatTokenCount(result.result.tokensAfter)})`
|
||||
: result.result?.tokensBefore
|
||||
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
|
||||
: "Compacted"
|
||||
? result.result?.tokensBefore
|
||||
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
|
||||
: "Compacted"
|
||||
: "Compaction skipped"
|
||||
: "Compaction failed";
|
||||
if (result.ok && result.compacted) {
|
||||
@@ -98,20 +103,8 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
||||
sessionStore: params.sessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
// Update token counts after compaction
|
||||
tokensAfter: result.result?.tokensAfter,
|
||||
});
|
||||
}
|
||||
// Use the post-compaction token count for context summary if available
|
||||
const tokensAfterCompaction = result.result?.tokensAfter;
|
||||
const totalTokens =
|
||||
tokensAfterCompaction ??
|
||||
params.sessionEntry.totalTokens ??
|
||||
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
|
||||
const contextSummary = formatContextUsageShort(
|
||||
totalTokens > 0 ? totalTokens : null,
|
||||
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
|
||||
);
|
||||
const reason = result.reason?.trim();
|
||||
const line = reason
|
||||
? `${compactLabel}: ${reason} • ${contextSummary}`
|
||||
|
||||
@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
isGroup: boolean;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
elevatedEnabled: boolean;
|
||||
|
||||
@@ -77,6 +77,100 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
expect(result?.text).not.toContain("failed");
|
||||
});
|
||||
|
||||
it("shows error message when sessionEntry is missing", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionStore = {};
|
||||
|
||||
const result = await handleDirectiveOnly({
|
||||
cfg: baseConfig(),
|
||||
directives,
|
||||
sessionEntry: undefined, // Missing!
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
storePath: "/tmp/sessions.json",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride: false,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
initialModelLabel: "anthropic/claude-opus-4-5",
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
expect(result?.text).toContain("failed");
|
||||
expect(result?.text).toContain("session state unavailable");
|
||||
});
|
||||
|
||||
it("shows error message when sessionStore is missing", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const result = await handleDirectiveOnly({
|
||||
cfg: baseConfig(),
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore: undefined, // Missing!
|
||||
sessionKey: "agent:main:dm:1",
|
||||
storePath: "/tmp/sessions.json",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride: false,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
initialModelLabel: "anthropic/claude-opus-4-5",
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
expect(result?.text).toContain("failed");
|
||||
expect(result?.text).toContain("session state unavailable");
|
||||
});
|
||||
|
||||
it("shows error message when sessionKey is missing", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
|
||||
const result = await handleDirectiveOnly({
|
||||
cfg: baseConfig(),
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: undefined, // Missing!
|
||||
storePath: "/tmp/sessions.json",
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-opus-4-5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelKeys,
|
||||
allowedModelCatalog,
|
||||
resetModelOverride: false,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
initialModelLabel: "anthropic/claude-opus-4-5",
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
expect(result?.text).toContain("failed");
|
||||
expect(result?.text).toContain("session state unavailable");
|
||||
});
|
||||
|
||||
it("shows no model message when no /model directive", async () => {
|
||||
const directives = parseInlineDirectives("hello world");
|
||||
const sessionEntry: SessionEntry = {
|
||||
|
||||
@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
|
||||
export async function handleDirectiveOnly(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
directives: InlineDirectives;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
elevatedEnabled: boolean;
|
||||
@@ -288,110 +288,114 @@ export async function handleDirectiveOnly(params: {
|
||||
nextThinkLevel === "xhigh" &&
|
||||
!supportsXHighThinking(resolvedProvider, resolvedModel);
|
||||
|
||||
const prevElevatedLevel =
|
||||
currentElevatedLevel ??
|
||||
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
||||
const prevReasoningLevel =
|
||||
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
let elevatedChanged =
|
||||
directives.hasElevatedDirective &&
|
||||
directives.elevatedLevel !== undefined &&
|
||||
elevatedEnabled &&
|
||||
elevatedAllowed;
|
||||
let reasoningChanged =
|
||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
|
||||
else sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
}
|
||||
if (shouldDowngradeXHigh) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
}
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
|
||||
else sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||
reasoningChanged =
|
||||
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
|
||||
}
|
||||
if (directives.hasElevatedDirective && directives.elevatedLevel) {
|
||||
// Unlike other toggles, elevated defaults can be "on".
|
||||
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
|
||||
sessionEntry.elevatedLevel = directives.elevatedLevel;
|
||||
elevatedChanged =
|
||||
elevatedChanged ||
|
||||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
|
||||
}
|
||||
if (directives.hasExecDirective && directives.hasExecOptions) {
|
||||
if (directives.execHost) {
|
||||
sessionEntry.execHost = directives.execHost;
|
||||
let didPersistModel = false;
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
const prevElevatedLevel =
|
||||
currentElevatedLevel ??
|
||||
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
||||
const prevReasoningLevel =
|
||||
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
let elevatedChanged =
|
||||
directives.hasElevatedDirective &&
|
||||
directives.elevatedLevel !== undefined &&
|
||||
elevatedEnabled &&
|
||||
elevatedAllowed;
|
||||
let reasoningChanged =
|
||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
|
||||
else sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
}
|
||||
if (directives.execSecurity) {
|
||||
sessionEntry.execSecurity = directives.execSecurity;
|
||||
if (shouldDowngradeXHigh) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
}
|
||||
if (directives.execAsk) {
|
||||
sessionEntry.execAsk = directives.execAsk;
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
}
|
||||
if (directives.execNode) {
|
||||
sessionEntry.execNode = directives.execNode;
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
|
||||
else sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||
reasoningChanged =
|
||||
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
|
||||
}
|
||||
}
|
||||
if (modelSelection) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
} else if (directives.hasQueueDirective) {
|
||||
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
|
||||
if (typeof directives.debounceMs === "number") {
|
||||
sessionEntry.queueDebounceMs = directives.debounceMs;
|
||||
if (directives.hasElevatedDirective && directives.elevatedLevel) {
|
||||
// Unlike other toggles, elevated defaults can be "on".
|
||||
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
|
||||
sessionEntry.elevatedLevel = directives.elevatedLevel;
|
||||
elevatedChanged =
|
||||
elevatedChanged ||
|
||||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
|
||||
}
|
||||
if (typeof directives.cap === "number") {
|
||||
sessionEntry.queueCap = directives.cap;
|
||||
if (directives.hasExecDirective && directives.hasExecOptions) {
|
||||
if (directives.execHost) {
|
||||
sessionEntry.execHost = directives.execHost;
|
||||
}
|
||||
if (directives.execSecurity) {
|
||||
sessionEntry.execSecurity = directives.execSecurity;
|
||||
}
|
||||
if (directives.execAsk) {
|
||||
sessionEntry.execAsk = directives.execAsk;
|
||||
}
|
||||
if (directives.execNode) {
|
||||
sessionEntry.execNode = directives.execNode;
|
||||
}
|
||||
}
|
||||
if (directives.dropPolicy) {
|
||||
sessionEntry.queueDrop = directives.dropPolicy;
|
||||
if (modelSelection) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
didPersistModel = true;
|
||||
}
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
if (modelSelection) {
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
if (nextLabel !== initialModelLabel) {
|
||||
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
} else if (directives.hasQueueDirective) {
|
||||
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
|
||||
if (typeof directives.debounceMs === "number") {
|
||||
sessionEntry.queueDebounceMs = directives.debounceMs;
|
||||
}
|
||||
if (typeof directives.cap === "number") {
|
||||
sessionEntry.queueCap = directives.cap;
|
||||
}
|
||||
if (directives.dropPolicy) {
|
||||
sessionEntry.queueDrop = directives.dropPolicy;
|
||||
}
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
if (modelSelection) {
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
if (nextLabel !== initialModelLabel) {
|
||||
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
|
||||
sessionKey,
|
||||
contextKey: `model:${nextLabel}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (elevatedChanged) {
|
||||
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
|
||||
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
|
||||
sessionKey,
|
||||
contextKey: `model:${nextLabel}`,
|
||||
contextKey: "mode:elevated",
|
||||
});
|
||||
}
|
||||
if (reasoningChanged) {
|
||||
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
|
||||
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
|
||||
sessionKey,
|
||||
contextKey: "mode:reasoning",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (elevatedChanged) {
|
||||
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
|
||||
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
|
||||
sessionKey,
|
||||
contextKey: "mode:elevated",
|
||||
});
|
||||
}
|
||||
if (reasoningChanged) {
|
||||
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
|
||||
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
|
||||
sessionKey,
|
||||
contextKey: "mode:reasoning",
|
||||
});
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
@@ -445,7 +449,7 @@ export async function handleDirectiveOnly(params: {
|
||||
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
|
||||
);
|
||||
}
|
||||
if (modelSelection) {
|
||||
if (modelSelection && didPersistModel) {
|
||||
const label = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
|
||||
parts.push(
|
||||
@@ -456,6 +460,10 @@ export async function handleDirectiveOnly(params: {
|
||||
if (profileOverride) {
|
||||
parts.push(`Auth profile set to ${profileOverride}.`);
|
||||
}
|
||||
} else if (modelSelection && !didPersistModel) {
|
||||
parts.push(
|
||||
`Model switch to ${modelSelection.provider}/${modelSelection.model} failed (session state unavailable).`,
|
||||
);
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueMode) {
|
||||
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
|
||||
|
||||
@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
|
||||
|
||||
@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
|
||||
workspaceDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
|
||||
|
||||
@@ -237,42 +237,23 @@ export async function incrementCompactionCount(params: {
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
now?: number;
|
||||
/** Token count after compaction - if provided, updates session token counts */
|
||||
tokensAfter?: number;
|
||||
}): Promise<number | undefined> {
|
||||
const {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
now = Date.now(),
|
||||
tokensAfter,
|
||||
} = params;
|
||||
const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now() } = params;
|
||||
if (!sessionStore || !sessionKey) return undefined;
|
||||
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
||||
if (!entry) return undefined;
|
||||
const nextCount = (entry.compactionCount ?? 0) + 1;
|
||||
// Build update payload with compaction count and optionally updated token counts
|
||||
const updates: Partial<SessionEntry> = {
|
||||
compactionCount: nextCount,
|
||||
updatedAt: now,
|
||||
};
|
||||
// If tokensAfter is provided, update the cached token counts to reflect post-compaction state
|
||||
if (tokensAfter != null && tokensAfter > 0) {
|
||||
updates.totalTokens = tokensAfter;
|
||||
// Clear input/output breakdown since we only have the total estimate after compaction
|
||||
updates.inputTokens = undefined;
|
||||
updates.outputTokens = undefined;
|
||||
}
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
...updates,
|
||||
compactionCount: nextCount,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = {
|
||||
...store[sessionKey],
|
||||
...updates,
|
||||
compactionCount: nextCount,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user