chore: merge origin/main
This commit is contained in:
commit
f489b6e7a5
@ -53,6 +53,7 @@
|
|||||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||||
|
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
- Voice wake forwarding tips:
|
- Voice wake forwarding tips:
|
||||||
- Command template should stay `clawdis-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
- Command template should stay `clawdis-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||||
|
|||||||
22
CHANGELOG.md
22
CHANGELOG.md
@ -11,16 +11,25 @@
|
|||||||
- `skillsInstall.*` → `skills.install.*`
|
- `skillsInstall.*` → `skills.install.*`
|
||||||
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
||||||
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills)
|
- new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills)
|
||||||
|
- Sessions: group keys now use `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized.
|
||||||
|
- Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`.
|
||||||
|
- Providers: Discord/Telegram no longer auto-start from env tokens alone; add `discord: { enabled: true }` / `telegram: { enabled: true }` to your config when using `DISCORD_BOT_TOKEN` / `TELEGRAM_BOT_TOKEN`.
|
||||||
|
- Config: remove `routing.allowFrom`; use `whatsapp.allowFrom` instead (run `clawdis doctor` to migrate).
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||||
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||||
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
|
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
|
||||||
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
||||||
|
- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.
|
||||||
|
- Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.
|
||||||
- Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider.
|
- Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider.
|
||||||
- iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
|
- iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.
|
||||||
- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.
|
- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.
|
||||||
- Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context.
|
- Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context.
|
||||||
|
- Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).
|
||||||
|
- Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).
|
||||||
|
- Skills: add Trello skill for board/list/card management (thanks @clawd).
|
||||||
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
|
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
|
||||||
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
|
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
|
||||||
- CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.
|
- CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.
|
||||||
@ -36,11 +45,19 @@
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
||||||
|
- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.
|
||||||
|
- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.
|
||||||
|
- Agent prompt: remove hardcoded user name in system prompt example.
|
||||||
|
- Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).
|
||||||
|
- Control UI: refine Web Chat session selector styling (chevron spacing + background).
|
||||||
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
|
- WebChat: stream live updates for sessions even when runs start outside the chat UI.
|
||||||
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
|
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
|
||||||
- Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks.
|
- Auto-reply: strip stray leading/trailing `HEARTBEAT_OK` from normal replies; drop short (≤ 30 chars) heartbeat acks.
|
||||||
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
|
- Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.
|
||||||
|
- Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.
|
||||||
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
|
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
|
||||||
|
- Discord: include author tag + id in group context `[from:]` lines for ping-ready replies (thanks @thewilloftheshadow).
|
||||||
|
- Gateway: fix TypeScript build by aligning hook mapping `channel` types and removing a dead Group DM branch in Discord monitor.
|
||||||
- Skills: switch imsg installer to brew tap formula.
|
- Skills: switch imsg installer to brew tap formula.
|
||||||
- Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.
|
- Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.
|
||||||
- Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.
|
- Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.
|
||||||
@ -49,8 +66,11 @@
|
|||||||
- CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).
|
- CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).
|
||||||
- CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.
|
- CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.
|
||||||
- CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.
|
- CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.
|
||||||
|
- CLI onboarding: always prompt for WhatsApp `whatsapp.allowFrom` and print (optionally open) the Control UI URL when done.
|
||||||
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
|
- CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).
|
||||||
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
|
- macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.
|
||||||
|
- macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect.
|
||||||
|
- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.
|
||||||
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
||||||
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
|
- macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b
|
||||||
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
||||||
@ -59,6 +79,7 @@
|
|||||||
- Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl
|
- Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl
|
||||||
- Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.
|
- Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.
|
||||||
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.
|
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.
|
||||||
|
- Cron: prevent `every` schedules without an anchor from firing in a tight loop (thanks @jamesgroat).
|
||||||
- Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock
|
- Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock
|
||||||
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
|
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
|
||||||
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
|
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
|
||||||
@ -102,6 +123,7 @@
|
|||||||
- iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.
|
- iOS/Android Talk Mode: explicitly `chat.subscribe` when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.
|
||||||
- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.
|
- Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.
|
||||||
- Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs.
|
- Gateway: `voice.transcript` now also maps agent bus output to `chat` events, ensuring chat UIs refresh for voice-triggered runs.
|
||||||
|
- Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.
|
||||||
- iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.
|
- iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.
|
||||||
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
|
||||||
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.
|
||||||
|
|||||||
@ -20,7 +20,7 @@ It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMe
|
|||||||
|
|
||||||
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
|
||||||
|
|
||||||
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Discord: https://discord.gg/qkhbAGHRBT
|
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Discord: https://discord.gg/qkhbAGHRBT
|
||||||
|
|
||||||
Preferred setup: run the onboarding wizard (`clawdis onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
Preferred setup: run the onboarding wizard (`clawdis onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||||
|
|
||||||
@ -157,7 +157,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
|||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["+1234567890"]
|
allowFrom: ["+1234567890"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -166,7 +166,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
|||||||
### WhatsApp
|
### WhatsApp
|
||||||
|
|
||||||
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||||
- Allowlist who can talk to the assistant via `routing.allowFrom`.
|
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|
||||||
@ -184,7 +184,7 @@ Minimal `~/.clawdis/clawdis.json`:
|
|||||||
### Discord
|
### Discord
|
||||||
|
|
||||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||||
- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed.
|
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
|
|||||||
@ -469,7 +469,8 @@ class ChatController(
|
|||||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||||
if (key.isEmpty()) return@mapNotNull null
|
if (key.isEmpty()) return@mapNotNull null
|
||||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt)
|
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||||
|
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ data class ChatPendingToolCall(
|
|||||||
data class ChatSessionEntry(
|
data class ChatSessionEntry(
|
||||||
val key: String,
|
val key: String,
|
||||||
val updatedAtMs: Long?,
|
val updatedAtMs: Long?,
|
||||||
|
val displayName: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ChatHistory(
|
data class ChatHistory(
|
||||||
|
|||||||
@ -62,6 +62,8 @@ fun ChatComposer(
|
|||||||
var showSessionMenu by remember { mutableStateOf(false) }
|
var showSessionMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||||
|
val currentSessionLabel =
|
||||||
|
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||||
|
|
||||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||||
|
|
||||||
@ -82,13 +84,13 @@ fun ChatComposer(
|
|||||||
onClick = { showSessionMenu = true },
|
onClick = { showSessionMenu = true },
|
||||||
contentPadding = ButtonDefaults.ContentPadding,
|
contentPadding = ButtonDefaults.ContentPadding,
|
||||||
) {
|
) {
|
||||||
Text("Session: $sessionKey")
|
Text("Session: $currentSessionLabel")
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
||||||
for (entry in sessionOptions) {
|
for (entry in sessionOptions) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(entry.key) },
|
text = { Text(entry.displayName ?: entry.key) },
|
||||||
onClick = {
|
onClick = {
|
||||||
onSelectSession(entry.key)
|
onSelectSession(entry.key)
|
||||||
showSessionMenu = false
|
showSessionMenu = false
|
||||||
|
|||||||
@ -82,7 +82,7 @@ private fun SessionRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Text(entry.key, style = MaterialTheme.typography.bodyMedium)
|
Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@ -90,4 +90,3 @@ private fun SessionRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -79,7 +79,7 @@ struct ContextMenuCardView: View {
|
|||||||
height: self.barHeight)
|
height: self.barHeight)
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Text(row.key)
|
Text(row.label)
|
||||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
|
|||||||
@ -63,9 +63,11 @@ final class ControlChannel {
|
|||||||
self.logger.info("control channel state -> connecting")
|
self.logger.info("control channel state -> connecting")
|
||||||
case .disconnected:
|
case .disconnected:
|
||||||
self.logger.info("control channel state -> disconnected")
|
self.logger.info("control channel state -> disconnected")
|
||||||
|
self.scheduleRecovery(reason: "disconnected")
|
||||||
case let .degraded(message):
|
case let .degraded(message):
|
||||||
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
|
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
|
||||||
self.logger.info("control channel state -> \(detail, privacy: .public)")
|
self.logger.info("control channel state -> \(detail, privacy: .public)")
|
||||||
|
self.scheduleRecovery(reason: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -74,6 +76,8 @@ final class ControlChannel {
|
|||||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||||
|
|
||||||
private var eventTask: Task<Void, Never>?
|
private var eventTask: Task<Void, Never>?
|
||||||
|
private var recoveryTask: Task<Void, Never>?
|
||||||
|
private var lastRecoveryAt: Date?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.startEventStream()
|
self.startEventStream()
|
||||||
@ -231,7 +235,43 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
|
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
|
||||||
return "Gateway error: \(detail)"
|
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed }
|
||||||
|
return "Gateway error: \(trimmed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRecovery(reason: String) {
|
||||||
|
let now = Date()
|
||||||
|
if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return }
|
||||||
|
guard self.recoveryTask == nil else { return }
|
||||||
|
self.lastRecoveryAt = now
|
||||||
|
|
||||||
|
self.recoveryTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
|
||||||
|
guard mode != .unconfigured else {
|
||||||
|
self.recoveryTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason
|
||||||
|
self.logger.info(
|
||||||
|
"control channel recovery starting mode=\(String(describing: mode), privacy: .public) reason=\(reasonText, privacy: .public)")
|
||||||
|
if mode == .local {
|
||||||
|
GatewayProcessManager.shared.setActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await GatewayConnection.shared.refresh()
|
||||||
|
self.logger.info("control channel recovery finished")
|
||||||
|
} catch {
|
||||||
|
self.logger.error(
|
||||||
|
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
self.recoveryTask = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
|
|||||||
@ -114,6 +114,7 @@ final class HealthStore {
|
|||||||
guard !self.isRefreshing else { return }
|
guard !self.isRefreshing else { return }
|
||||||
self.isRefreshing = true
|
self.isRefreshing = true
|
||||||
defer { self.isRefreshing = false }
|
defer { self.isRefreshing = false }
|
||||||
|
let previousError = self.lastError
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try await ControlChannel.shared.health(timeout: 15)
|
let data = try await ControlChannel.shared.health(timeout: 15)
|
||||||
@ -121,13 +122,23 @@ final class HealthStore {
|
|||||||
self.snapshot = decoded
|
self.snapshot = decoded
|
||||||
self.lastSuccess = Date()
|
self.lastSuccess = Date()
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
|
if previousError != nil {
|
||||||
|
Self.logger.info("health refresh recovered")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.lastError = "health output not JSON"
|
self.lastError = "health output not JSON"
|
||||||
if onDemand { self.snapshot = nil }
|
if onDemand { self.snapshot = nil }
|
||||||
|
if previousError != self.lastError {
|
||||||
|
Self.logger.warning("health refresh failed: output not JSON")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.lastError = error.localizedDescription
|
let desc = error.localizedDescription
|
||||||
|
self.lastError = desc
|
||||||
if onDemand { self.snapshot = nil }
|
if onDemand { self.snapshot = nil }
|
||||||
|
if previousError != desc {
|
||||||
|
Self.logger.error("health refresh failed \(desc, privacy: .public)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -374,6 +374,10 @@ struct MenuContent: View {
|
|||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(nil)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.layoutPriority(1)
|
||||||
}
|
}
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||||
let width = self.initialWidth(for: menu)
|
let width = self.initialWidth(for: menu)
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else { return }
|
||||||
menu.insertItem(self.makeMessageItem(
|
|
||||||
text: self.controlChannelStatusText,
|
|
||||||
symbolName: "wifi.slash",
|
|
||||||
width: width), at: insertIndex)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let snapshot = self.cachedSnapshot else {
|
guard let snapshot = self.cachedSnapshot else {
|
||||||
let headerItem = NSMenuItem()
|
let headerItem = NSMenuItem()
|
||||||
@ -195,17 +189,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
menu.insertItem(topSeparator, at: cursor)
|
menu.insertItem(topSeparator, at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
if let gatewayEntry = self.gatewayEntry() {
|
||||||
menu.insertItem(
|
let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width)
|
||||||
self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width),
|
menu.insertItem(gatewayItem, at: cursor)
|
||||||
at: cursor)
|
|
||||||
cursor += 1
|
cursor += 1
|
||||||
let separator = NSMenuItem.separator()
|
|
||||||
separator.tag = self.nodesTag
|
|
||||||
menu.insertItem(separator, at: cursor)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard self.isControlChannelConnected else { return }
|
||||||
|
|
||||||
if let error = self.nodesStore.lastError?.nonEmpty {
|
if let error = self.nodesStore.lastError?.nonEmpty {
|
||||||
menu.insertItem(
|
menu.insertItem(
|
||||||
self.makeMessageItem(
|
self.makeMessageItem(
|
||||||
@ -229,15 +220,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
cursor += 1
|
cursor += 1
|
||||||
} else {
|
} else {
|
||||||
for entry in entries.prefix(8) {
|
for entry in entries.prefix(8) {
|
||||||
let item = NSMenuItem()
|
let item = self.makeNodeItem(entry: entry, width: width)
|
||||||
item.tag = self.nodesTag
|
|
||||||
item.target = self
|
|
||||||
item.action = #selector(self.copyNodeSummary(_:))
|
|
||||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
|
||||||
item.view = HighlightedMenuItemHostView(
|
|
||||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
|
||||||
width: width)
|
|
||||||
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
|
||||||
menu.insertItem(item, at: cursor)
|
menu.insertItem(item, at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
}
|
}
|
||||||
@ -265,27 +248,56 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private var controlChannelStatusText: String {
|
private func gatewayEntry() -> NodeInfo? {
|
||||||
switch ControlChannel.shared.state {
|
let mode = AppStateStore.shared.connectionMode
|
||||||
case .connected:
|
let isConnected = self.isControlChannelConnected
|
||||||
return "Connected"
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
case .connecting:
|
var host: String?
|
||||||
return "Connecting to gateway…"
|
var platform: String?
|
||||||
case let .degraded(reason):
|
|
||||||
if self.shouldShowConnecting { return "Connecting to gateway…" }
|
switch mode {
|
||||||
return reason.nonEmpty ?? "No connection to gateway"
|
case .remote:
|
||||||
case .disconnected:
|
platform = "remote"
|
||||||
return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway"
|
let target = AppStateStore.shared.remoteTarget
|
||||||
|
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||||
|
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||||
|
} else {
|
||||||
|
host = target.nonEmpty
|
||||||
|
}
|
||||||
|
case .local:
|
||||||
|
platform = "local"
|
||||||
|
host = "127.0.0.1:\(port)"
|
||||||
|
case .unconfigured:
|
||||||
|
platform = nil
|
||||||
|
host = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return NodeInfo(
|
||||||
|
nodeId: "gateway",
|
||||||
|
displayName: "Gateway",
|
||||||
|
platform: platform,
|
||||||
|
version: nil,
|
||||||
|
deviceFamily: nil,
|
||||||
|
modelIdentifier: nil,
|
||||||
|
remoteIp: host,
|
||||||
|
caps: nil,
|
||||||
|
commands: nil,
|
||||||
|
permissions: nil,
|
||||||
|
paired: nil,
|
||||||
|
connected: isConnected)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowConnecting: Bool {
|
private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem {
|
||||||
switch GatewayProcessManager.shared.status {
|
let item = NSMenuItem()
|
||||||
case .starting, .running, .attachedExisting:
|
item.tag = self.nodesTag
|
||||||
return true
|
item.target = self
|
||||||
case .stopped, .failed:
|
item.action = #selector(self.copyNodeSummary(_:))
|
||||||
return false
|
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||||
}
|
item.view = HighlightedMenuItemHostView(
|
||||||
|
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||||
|
width: width)
|
||||||
|
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
||||||
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
||||||
@ -293,8 +305,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
Label(text, systemImage: symbolName)
|
Label(text, systemImage: symbolName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.multilineTextAlignment(.leading)
|
||||||
.truncationMode(.tail)
|
.lineLimit(nil)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding(.leading, 18)
|
.padding(.leading, 18)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|||||||
@ -2,15 +2,30 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct NodeMenuEntryFormatter {
|
struct NodeMenuEntryFormatter {
|
||||||
|
static func isGateway(_ entry: NodeInfo) -> Bool {
|
||||||
|
entry.nodeId == "gateway"
|
||||||
|
}
|
||||||
|
|
||||||
static func isConnected(_ entry: NodeInfo) -> Bool {
|
static func isConnected(_ entry: NodeInfo) -> Bool {
|
||||||
entry.isConnected
|
entry.isConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
static func primaryName(_ entry: NodeInfo) -> String {
|
static func primaryName(_ entry: NodeInfo) -> String {
|
||||||
entry.displayName?.nonEmpty ?? entry.nodeId
|
if self.isGateway(entry) {
|
||||||
|
return entry.displayName?.nonEmpty ?? "Gateway"
|
||||||
|
}
|
||||||
|
return entry.displayName?.nonEmpty ?? entry.nodeId
|
||||||
}
|
}
|
||||||
|
|
||||||
static func summaryText(_ entry: NodeInfo) -> String {
|
static func summaryText(_ entry: NodeInfo) -> String {
|
||||||
|
if self.isGateway(entry) {
|
||||||
|
let role = self.roleText(entry)
|
||||||
|
let name = self.primaryName(entry)
|
||||||
|
var parts = ["\(name) · \(role)"]
|
||||||
|
if let ip = entry.remoteIp?.nonEmpty { parts.append("host \(ip)") }
|
||||||
|
if let platform = self.platformText(entry) { parts.append(platform) }
|
||||||
|
return parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
let name = self.primaryName(entry)
|
let name = self.primaryName(entry)
|
||||||
var prefix = "Node: \(name)"
|
var prefix = "Node: \(name)"
|
||||||
if let ip = entry.remoteIp?.nonEmpty {
|
if let ip = entry.remoteIp?.nonEmpty {
|
||||||
@ -112,6 +127,11 @@ struct NodeMenuEntryFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||||
|
if self.isGateway(entry) {
|
||||||
|
return self.safeSystemSymbol(
|
||||||
|
"antenna.radiowaves.left.and.right",
|
||||||
|
fallback: "dot.radiowaves.left.and.right")
|
||||||
|
}
|
||||||
if let family = entry.deviceFamily?.lowercased() {
|
if let family = entry.deviceFamily?.lowercased() {
|
||||||
if family.contains("mac") {
|
if family.contains("mac") {
|
||||||
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
||||||
|
|||||||
@ -75,10 +75,26 @@ final class NodesStore {
|
|||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
self.statusMessage = nil
|
self.statusMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
|
if Self.isCancelled(error) {
|
||||||
|
self.logger.debug("node.list cancelled; keeping last nodes")
|
||||||
|
if self.nodes.isEmpty {
|
||||||
|
self.statusMessage = "Refreshing devices…"
|
||||||
|
}
|
||||||
|
self.lastError = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)")
|
self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)")
|
||||||
self.nodes = []
|
self.nodes = []
|
||||||
self.lastError = error.localizedDescription
|
self.lastError = error.localizedDescription
|
||||||
self.statusMessage = nil
|
self.statusMessage = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func isCancelled(_ error: Error) -> Bool {
|
||||||
|
if error is CancellationError { return true }
|
||||||
|
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
|
||||||
|
let nsError = error as NSError
|
||||||
|
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,8 @@ enum PermissionManager {
|
|||||||
await self.ensureMicrophone(interactive: interactive)
|
await self.ensureMicrophone(interactive: interactive)
|
||||||
case .speechRecognition:
|
case .speechRecognition:
|
||||||
await self.ensureSpeechRecognition(interactive: interactive)
|
await self.ensureSpeechRecognition(interactive: interactive)
|
||||||
|
case .camera:
|
||||||
|
await self.ensureCamera(interactive: interactive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +116,24 @@ enum PermissionManager {
|
|||||||
return SFSpeechRecognizer.authorizationStatus() == .authorized
|
return SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func ensureCamera(interactive: Bool) async -> Bool {
|
||||||
|
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
guard interactive else { return false }
|
||||||
|
return await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
case .denied, .restricted:
|
||||||
|
if interactive {
|
||||||
|
CameraPermissionHelper.openSettings()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func voiceWakePermissionsGranted() -> Bool {
|
static func voiceWakePermissionsGranted() -> Bool {
|
||||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
@ -153,6 +173,9 @@ enum PermissionManager {
|
|||||||
|
|
||||||
case .speechRecognition:
|
case .speechRecognition:
|
||||||
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
|
|
||||||
|
case .camera:
|
||||||
|
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
@ -189,6 +212,21 @@ enum MicrophonePermissionHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CameraPermissionHelper {
|
||||||
|
static func openSettings() {
|
||||||
|
let candidates = [
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum AppleScriptPermission {
|
enum AppleScriptPermission {
|
||||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission")
|
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission")
|
||||||
|
|
||||||
|
|||||||
@ -120,6 +120,7 @@ struct PermissionRow: View {
|
|||||||
case .screenRecording: "Screen Recording"
|
case .screenRecording: "Screen Recording"
|
||||||
case .microphone: "Microphone"
|
case .microphone: "Microphone"
|
||||||
case .speechRecognition: "Speech Recognition"
|
case .speechRecognition: "Speech Recognition"
|
||||||
|
case .camera: "Camera"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,6 +133,7 @@ struct PermissionRow: View {
|
|||||||
case .screenRecording: "Capture the screen for context or screenshots"
|
case .screenRecording: "Capture the screen for context or screenshots"
|
||||||
case .microphone: "Allow Voice Wake and audio capture"
|
case .microphone: "Allow Voice Wake and audio capture"
|
||||||
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
|
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
|
||||||
|
case .camera: "Capture photos and video from the camera"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,6 +145,7 @@ struct PermissionRow: View {
|
|||||||
case .screenRecording: "display"
|
case .screenRecording: "display"
|
||||||
case .microphone: "mic"
|
case .microphone: "mic"
|
||||||
case .speechRecognition: "waveform"
|
case .speechRecognition: "waveform"
|
||||||
|
case .camera: "camera"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,11 @@ struct GatewaySessionDefaultsRecord: Codable {
|
|||||||
|
|
||||||
struct GatewaySessionEntryRecord: Codable {
|
struct GatewaySessionEntryRecord: Codable {
|
||||||
let key: String
|
let key: String
|
||||||
|
let displayName: String?
|
||||||
|
let surface: String?
|
||||||
|
let subject: String?
|
||||||
|
let room: String?
|
||||||
|
let space: String?
|
||||||
let updatedAt: Double?
|
let updatedAt: Double?
|
||||||
let sessionId: String?
|
let sessionId: String?
|
||||||
let systemSent: Bool?
|
let systemSent: Bool?
|
||||||
@ -65,6 +70,11 @@ struct SessionRow: Identifiable {
|
|||||||
let id: String
|
let id: String
|
||||||
let key: String
|
let key: String
|
||||||
let kind: SessionKind
|
let kind: SessionKind
|
||||||
|
let displayName: String?
|
||||||
|
let surface: String?
|
||||||
|
let subject: String?
|
||||||
|
let room: String?
|
||||||
|
let space: String?
|
||||||
let updatedAt: Date?
|
let updatedAt: Date?
|
||||||
let sessionId: String?
|
let sessionId: String?
|
||||||
let thinkingLevel: String?
|
let thinkingLevel: String?
|
||||||
@ -75,6 +85,7 @@ struct SessionRow: Identifiable {
|
|||||||
let model: String?
|
let model: String?
|
||||||
|
|
||||||
var ageText: String { relativeAge(from: self.updatedAt) }
|
var ageText: String { relativeAge(from: self.updatedAt) }
|
||||||
|
var label: String { self.displayName ?? self.key }
|
||||||
|
|
||||||
var flagLabels: [String] {
|
var flagLabels: [String] {
|
||||||
var flags: [String] = []
|
var flags: [String] = []
|
||||||
@ -92,6 +103,8 @@ enum SessionKind {
|
|||||||
static func from(key: String) -> SessionKind {
|
static func from(key: String) -> SessionKind {
|
||||||
if key == "global" { return .global }
|
if key == "global" { return .global }
|
||||||
if key.hasPrefix("group:") { return .group }
|
if key.hasPrefix("group:") { return .group }
|
||||||
|
if key.contains(":group:") { return .group }
|
||||||
|
if key.contains(":channel:") { return .group }
|
||||||
if key == "unknown" { return .unknown }
|
if key == "unknown" { return .unknown }
|
||||||
return .direct
|
return .direct
|
||||||
}
|
}
|
||||||
@ -127,6 +140,11 @@ extension SessionRow {
|
|||||||
id: "direct-1",
|
id: "direct-1",
|
||||||
key: "user@example.com",
|
key: "user@example.com",
|
||||||
kind: .direct,
|
kind: .direct,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date().addingTimeInterval(-90),
|
updatedAt: Date().addingTimeInterval(-90),
|
||||||
sessionId: "sess-direct-1234",
|
sessionId: "sess-direct-1234",
|
||||||
thinkingLevel: "low",
|
thinkingLevel: "low",
|
||||||
@ -137,8 +155,13 @@ extension SessionRow {
|
|||||||
model: "claude-3.5-sonnet"),
|
model: "claude-3.5-sonnet"),
|
||||||
SessionRow(
|
SessionRow(
|
||||||
id: "group-1",
|
id: "group-1",
|
||||||
key: "group:engineering",
|
key: "discord:channel:release-squad",
|
||||||
kind: .group,
|
kind: .group,
|
||||||
|
displayName: "discord:#release-squad",
|
||||||
|
surface: "discord",
|
||||||
|
subject: nil,
|
||||||
|
room: "#release-squad",
|
||||||
|
space: nil,
|
||||||
updatedAt: Date().addingTimeInterval(-3600),
|
updatedAt: Date().addingTimeInterval(-3600),
|
||||||
sessionId: "sess-group-4321",
|
sessionId: "sess-group-4321",
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
@ -151,6 +174,11 @@ extension SessionRow {
|
|||||||
id: "global",
|
id: "global",
|
||||||
key: "global",
|
key: "global",
|
||||||
kind: .global,
|
kind: .global,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date().addingTimeInterval(-86400),
|
updatedAt: Date().addingTimeInterval(-86400),
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
thinkingLevel: nil,
|
thinkingLevel: nil,
|
||||||
@ -269,6 +297,11 @@ enum SessionLoader {
|
|||||||
id: entry.key,
|
id: entry.key,
|
||||||
key: entry.key,
|
key: entry.key,
|
||||||
kind: SessionKind.from(key: entry.key),
|
kind: SessionKind.from(key: entry.key),
|
||||||
|
displayName: entry.displayName,
|
||||||
|
surface: entry.surface,
|
||||||
|
subject: entry.subject,
|
||||||
|
room: entry.room,
|
||||||
|
space: entry.space,
|
||||||
updatedAt: updated,
|
updatedAt: updated,
|
||||||
sessionId: entry.sessionId,
|
sessionId: entry.sessionId,
|
||||||
thinkingLevel: entry.thinkingLevel,
|
thinkingLevel: entry.thinkingLevel,
|
||||||
|
|||||||
@ -36,7 +36,7 @@ struct SessionMenuLabelView: View {
|
|||||||
height: self.barHeight)
|
height: self.barHeight)
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Text(self.row.key)
|
Text(self.row.label)
|
||||||
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
|
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
|
||||||
.foregroundStyle(self.primaryTextColor)
|
.foregroundStyle(self.primaryTextColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|||||||
@ -89,7 +89,7 @@ struct SessionsSettings: View {
|
|||||||
private func sessionRow(_ row: SessionRow) -> some View {
|
private func sessionRow(_ row: SessionRow) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Text(row.key)
|
Text(row.label)
|
||||||
.font(.subheadline.bold())
|
.font(.subheadline.bold())
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
|
|||||||
case screenRecording
|
case screenRecording
|
||||||
case microphone
|
case microphone
|
||||||
case speechRecognition
|
case speechRecognition
|
||||||
|
case camera
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum CameraFacing: String, Codable, Sendable {
|
public enum CameraFacing: String, Codable, Sendable {
|
||||||
|
|||||||
@ -29,6 +29,11 @@ struct MenuSessionsInjectorTests {
|
|||||||
id: "main",
|
id: "main",
|
||||||
key: "main",
|
key: "main",
|
||||||
kind: .direct,
|
kind: .direct,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date(),
|
updatedAt: Date(),
|
||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
thinkingLevel: "low",
|
thinkingLevel: "low",
|
||||||
@ -38,9 +43,14 @@ struct MenuSessionsInjectorTests {
|
|||||||
tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000),
|
tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000),
|
||||||
model: "claude-opus-4-5"),
|
model: "claude-opus-4-5"),
|
||||||
SessionRow(
|
SessionRow(
|
||||||
id: "group:alpha",
|
id: "discord:group:alpha",
|
||||||
key: "group:alpha",
|
key: "discord:group:alpha",
|
||||||
kind: .group,
|
kind: .group,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date(timeIntervalSinceNow: -60),
|
updatedAt: Date(timeIntervalSinceNow: -60),
|
||||||
sessionId: "s2",
|
sessionId: "s2",
|
||||||
thinkingLevel: "high",
|
thinkingLevel: "high",
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import Testing
|
|||||||
struct SessionDataTests {
|
struct SessionDataTests {
|
||||||
@Test func sessionKindFromKeyDetectsCommonKinds() {
|
@Test func sessionKindFromKeyDetectsCommonKinds() {
|
||||||
#expect(SessionKind.from(key: "global") == .global)
|
#expect(SessionKind.from(key: "global") == .global)
|
||||||
#expect(SessionKind.from(key: "group:engineering") == .group)
|
#expect(SessionKind.from(key: "discord:group:engineering") == .group)
|
||||||
#expect(SessionKind.from(key: "unknown") == .unknown)
|
#expect(SessionKind.from(key: "unknown") == .unknown)
|
||||||
#expect(SessionKind.from(key: "user@example.com") == .direct)
|
#expect(SessionKind.from(key: "user@example.com") == .direct)
|
||||||
}
|
}
|
||||||
@ -27,6 +27,11 @@ struct SessionDataTests {
|
|||||||
id: "x",
|
id: "x",
|
||||||
key: "user@example.com",
|
key: "user@example.com",
|
||||||
kind: .direct,
|
kind: .direct,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date(),
|
updatedAt: Date(),
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
thinkingLevel: "high",
|
thinkingLevel: "high",
|
||||||
@ -41,4 +46,3 @@ struct SessionDataTests {
|
|||||||
#expect(row.flagLabels.contains("aborted"))
|
#expect(row.flagLabels.contains("aborted"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,9 @@ struct WorkActivityStoreTests {
|
|||||||
@Test func mainSessionJobPreemptsOther() {
|
@Test func mainSessionJobPreemptsOther() {
|
||||||
let store = WorkActivityStore()
|
let store = WorkActivityStore()
|
||||||
|
|
||||||
store.handleJob(sessionKey: "group:1", state: "started")
|
store.handleJob(sessionKey: "discord:group:1", state: "started")
|
||||||
#expect(store.iconState == .workingOther(.job))
|
#expect(store.iconState == .workingOther(.job))
|
||||||
#expect(store.current?.sessionKey == "group:1")
|
#expect(store.current?.sessionKey == "discord:group:1")
|
||||||
|
|
||||||
store.handleJob(sessionKey: "main", state: "started")
|
store.handleJob(sessionKey: "main", state: "started")
|
||||||
#expect(store.iconState == .workingMain(.job))
|
#expect(store.iconState == .workingMain(.job))
|
||||||
@ -18,9 +18,9 @@ struct WorkActivityStoreTests {
|
|||||||
|
|
||||||
store.handleJob(sessionKey: "main", state: "finished")
|
store.handleJob(sessionKey: "main", state: "finished")
|
||||||
#expect(store.iconState == .workingOther(.job))
|
#expect(store.iconState == .workingOther(.job))
|
||||||
#expect(store.current?.sessionKey == "group:1")
|
#expect(store.current?.sessionKey == "discord:group:1")
|
||||||
|
|
||||||
store.handleJob(sessionKey: "group:1", state: "finished")
|
store.handleJob(sessionKey: "discord:group:1", state: "finished")
|
||||||
#expect(store.iconState == .idle)
|
#expect(store.iconState == .idle)
|
||||||
#expect(store.current == nil)
|
#expect(store.current == nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,7 +103,7 @@ struct ClawdisChatComposer: View {
|
|||||||
set: { next in self.viewModel.switchSession(to: next) }))
|
set: { next in self.viewModel.switchSession(to: next) }))
|
||||||
{
|
{
|
||||||
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
|
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
|
||||||
Text(session.key)
|
Text(session.displayName ?? session.key)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.tag(session.key)
|
.tag(session.key)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,11 @@ public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable
|
|||||||
|
|
||||||
public let key: String
|
public let key: String
|
||||||
public let kind: String?
|
public let kind: String?
|
||||||
|
public let displayName: String?
|
||||||
|
public let surface: String?
|
||||||
|
public let subject: String?
|
||||||
|
public let room: String?
|
||||||
|
public let space: String?
|
||||||
public let updatedAt: Double?
|
public let updatedAt: Double?
|
||||||
public let sessionId: String?
|
public let sessionId: String?
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ struct ChatSessionsSheet: View {
|
|||||||
self.dismiss()
|
self.dismiss()
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(session.key)
|
Text(session.displayName ?? session.key)
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
||||||
|
|||||||
@ -23,7 +23,7 @@ public struct ClawdisChatView: View {
|
|||||||
static let composerPaddingHorizontal: CGFloat = 0
|
static let composerPaddingHorizontal: CGFloat = 0
|
||||||
static let stackSpacing: CGFloat = 0
|
static let stackSpacing: CGFloat = 0
|
||||||
static let messageSpacing: CGFloat = 6
|
static let messageSpacing: CGFloat = 6
|
||||||
static let messageListPaddingTop: CGFloat = 0
|
static let messageListPaddingTop: CGFloat = 12
|
||||||
static let messageListPaddingBottom: CGFloat = 16
|
static let messageListPaddingBottom: CGFloat = 16
|
||||||
static let messageListPaddingHorizontal: CGFloat = 6
|
static let messageListPaddingHorizontal: CGFloat = 6
|
||||||
#else
|
#else
|
||||||
@ -32,7 +32,7 @@ public struct ClawdisChatView: View {
|
|||||||
static let composerPaddingHorizontal: CGFloat = 6
|
static let composerPaddingHorizontal: CGFloat = 6
|
||||||
static let stackSpacing: CGFloat = 6
|
static let stackSpacing: CGFloat = 6
|
||||||
static let messageSpacing: CGFloat = 12
|
static let messageSpacing: CGFloat = 12
|
||||||
static let messageListPaddingTop: CGFloat = 4
|
static let messageListPaddingTop: CGFloat = 10
|
||||||
static let messageListPaddingBottom: CGFloat = 6
|
static let messageListPaddingBottom: CGFloat = 6
|
||||||
static let messageListPaddingHorizontal: CGFloat = 8
|
static let messageListPaddingHorizontal: CGFloat = 8
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -341,6 +341,11 @@ public final class ClawdisChatViewModel {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: key,
|
key: key,
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: nil,
|
updatedAt: nil,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
|
|||||||
@ -282,6 +282,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "recent-1",
|
key: "recent-1",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: recent,
|
updatedAt: recent,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
@ -296,6 +301,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "main",
|
key: "main",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: stale,
|
updatedAt: stale,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
@ -310,6 +320,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "recent-2",
|
key: "recent-2",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: recentOlder,
|
updatedAt: recentOlder,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
@ -324,6 +339,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "old-1",
|
key: "old-1",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: stale,
|
updatedAt: stale,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
@ -365,6 +385,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "main",
|
key: "main",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: recent,
|
updatedAt: recent,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
|
|||||||
@ -83,7 +83,7 @@ git commit -m "Add Clawd workspace"
|
|||||||
## What Clawdis Does
|
## What Clawdis Does
|
||||||
- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac.
|
- Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac.
|
||||||
- macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdis` CLI via its bundled binary.
|
- macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdis` CLI via its bundled binary.
|
||||||
- Direct chats collapse into the shared `main` session by default; groups stay isolated as `group:<jid>`; heartbeats keep background tasks alive.
|
- Direct chats collapse into the shared `main` session by default; groups stay isolated as `surface:group:<id>` (rooms: `surface:channel:<id>`); heartbeats keep background tasks alive.
|
||||||
|
|
||||||
## Core Skills (enable in Settings → Skills)
|
## Core Skills (enable in Settings → Skills)
|
||||||
- **mcporter** — Tool server runtime/CLI for managing external skill backends.
|
- **mcporter** — Tool server runtime/CLI for managing external skill backends.
|
||||||
|
|||||||
@ -76,7 +76,7 @@ Incoming user messages are queued while the agent is streaming. The queue is che
|
|||||||
|
|
||||||
At minimum, set:
|
At minimum, set:
|
||||||
- `agent.workspace`
|
- `agent.workspace`
|
||||||
- `routing.allowFrom` (strongly recommended)
|
- `whatsapp.allowFrom` (strongly recommended)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ You’re putting an agent in a position to:
|
|||||||
- send messages back out via WhatsApp/Telegram/Discord
|
- send messages back out via WhatsApp/Telegram/Discord
|
||||||
|
|
||||||
Start conservative:
|
Start conservative:
|
||||||
- Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac).
|
- Always set `whatsapp.allowFrom` (never run open-to-the-world on your personal Mac).
|
||||||
- Use a dedicated WhatsApp number for the assistant.
|
- Use a dedicated WhatsApp number for the assistant.
|
||||||
- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
|
- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`).
|
||||||
|
|
||||||
@ -74,7 +74,7 @@ clawdis gateway --port 18789
|
|||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["+15555550123"]
|
allowFrom: ["+15555550123"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,8 +124,10 @@ Example:
|
|||||||
// Start with 0; enable later.
|
// Start with 0; enable later.
|
||||||
heartbeat: { every: "0m" }
|
heartbeat: { every: "0m" }
|
||||||
},
|
},
|
||||||
|
whatsapp: {
|
||||||
|
allowFrom: ["+15555550123"]
|
||||||
|
},
|
||||||
routing: {
|
routing: {
|
||||||
allowFrom: ["+15555550123"],
|
|
||||||
groupChat: {
|
groupChat: {
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
mentionPatterns: ["@clawd", "clawd"]
|
mentionPatterns: ["@clawd", "clawd"]
|
||||||
|
|||||||
@ -9,7 +9,7 @@ read_when:
|
|||||||
CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comments + trailing commas allowed).
|
CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comments + trailing commas allowed).
|
||||||
|
|
||||||
If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
|
If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
|
||||||
- restrict who can trigger the bot (`routing.allowFrom`)
|
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
|
||||||
- tune group mention behavior (`routing.groupChat`)
|
- tune group mention behavior (`routing.groupChat`)
|
||||||
- customize message prefixes (`messages`)
|
- customize message prefixes (`messages`)
|
||||||
- set the agent’s workspace (`agent.workspace`)
|
- set the agent’s workspace (`agent.workspace`)
|
||||||
@ -21,7 +21,7 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
|
|||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agent: { workspace: "~/clawd" },
|
agent: { workspace: "~/clawd" },
|
||||||
routing: { allowFrom: ["+15555550123"] }
|
whatsapp: { allowFrom: ["+15555550123"] }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -76,13 +76,13 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `routing.allowFrom`
|
### `whatsapp.allowFrom`
|
||||||
|
|
||||||
Allowlist of E.164 phone numbers that may trigger auto-replies.
|
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
routing: { allowFrom: ["+15555550123", "+447700900123"] }
|
whatsapp: { allowFrom: ["+15555550123", "+447700900123"] }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -145,7 +145,7 @@ Set `web.enabled: false` to keep it off by default.
|
|||||||
|
|
||||||
### `telegram` (bot transport)
|
### `telegram` (bot transport)
|
||||||
|
|
||||||
Clawdis reads `TELEGRAM_BOT_TOKEN` or `telegram.botToken` to start the provider.
|
Clawdis starts Telegram only when a `telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `telegram.botToken`.
|
||||||
Set `telegram.enabled: false` to disable automatic startup.
|
Set `telegram.enabled: false` to disable automatic startup.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@ -173,20 +173,38 @@ Configure the Discord bot by setting the bot token and optional gating:
|
|||||||
discord: {
|
discord: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: "your-bot-token",
|
token: "your-bot-token",
|
||||||
allowFrom: ["discord:1234567890", "*"], // optional DM allowlist (user ids)
|
|
||||||
guildAllowFrom: {
|
|
||||||
guilds: ["123456789012345678"], // optional guild allowlist (ids)
|
|
||||||
users: ["987654321098765432"] // optional user allowlist (ids)
|
|
||||||
},
|
|
||||||
requireMention: true, // require @bot mentions in guilds
|
|
||||||
mediaMaxMb: 8, // clamp inbound media size
|
mediaMaxMb: 8, // clamp inbound media size
|
||||||
historyLimit: 20, // include last N guild messages as context
|
enableReactions: true, // allow agent-triggered reactions
|
||||||
enableReactions: true // allow agent-triggered reactions
|
slashCommand: { // user-installed app slash commands
|
||||||
|
enabled: true,
|
||||||
|
name: "clawd",
|
||||||
|
sessionPrefix: "discord:slash",
|
||||||
|
ephemeral: true
|
||||||
|
},
|
||||||
|
dm: {
|
||||||
|
enabled: true, // disable all DMs when false
|
||||||
|
allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names)
|
||||||
|
groupEnabled: false, // enable group DMs
|
||||||
|
groupChannels: ["clawd-dm"] // optional group DM allowlist
|
||||||
|
},
|
||||||
|
guilds: {
|
||||||
|
"123456789012345678": { // guild id (preferred) or slug
|
||||||
|
slug: "friends-of-clawd",
|
||||||
|
requireMention: false, // per-guild default
|
||||||
|
users: ["987654321098765432"], // optional per-guild user allowlist
|
||||||
|
channels: {
|
||||||
|
general: { allow: true },
|
||||||
|
help: { allow: true, requireMention: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
historyLimit: 20 // include last N guild messages as context
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Clawdis reads `DISCORD_BOT_TOKEN` or `discord.token` to start the provider (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
|
Clawdis starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
|
||||||
|
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
|
||||||
|
|
||||||
### `imessage` (imsg CLI)
|
### `imessage` (imsg CLI)
|
||||||
|
|
||||||
@ -537,7 +555,7 @@ Defaults:
|
|||||||
mode: "local", // or "remote"
|
mode: "local", // or "remote"
|
||||||
bind: "loopback",
|
bind: "loopback",
|
||||||
// controlUi: { enabled: true }
|
// controlUi: { enabled: true }
|
||||||
// auth: { mode: "token" | "password" }
|
// auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access
|
||||||
// tailscale: { mode: "off" | "serve" | "funnel" }
|
// tailscale: { mode: "off" | "serve" | "funnel" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -548,6 +566,7 @@ Notes:
|
|||||||
|
|
||||||
Auth and Tailscale:
|
Auth and Tailscale:
|
||||||
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
||||||
|
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
||||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||||
- `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended).
|
- `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended).
|
||||||
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
|
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
|
||||||
|
|||||||
@ -11,22 +11,27 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Talk to Clawdis via Discord DMs or guild channels.
|
- Talk to Clawdis via Discord DMs or guild channels.
|
||||||
- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `group:<channelId>`.
|
- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `discord:group:<channelId>` (display names use `discord:<guildSlug>#<channelSlug>`).
|
||||||
|
- Group DMs are ignored by default; enable via `discord.dm.groupEnabled` and optionally restrict by `discord.dm.groupChannels`.
|
||||||
- Keep routing deterministic: replies always go back to the surface they arrived on.
|
- Keep routing deterministic: replies always go back to the surface they arrived on.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
|
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
|
||||||
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
|
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
|
||||||
3. Configure Clawdis with `DISCORD_BOT_TOKEN` (or `discord.token` in `~/.clawdis/clawdis.json`).
|
3. Configure Clawdis with `DISCORD_BOT_TOKEN` (or `discord.token` in `~/.clawdis/clawdis.json`).
|
||||||
4. Run the gateway; it auto-starts the Discord provider when the token is set (unless `discord.enabled = false`).
|
4. Run the gateway; it auto-starts the Discord provider only when a `discord` config section exists **and** the token is set (unless `discord.enabled = false`).
|
||||||
|
- If you prefer env vars, still add `discord: { enabled: true }` to `~/.clawdis/clawdis.json` and set `DISCORD_BOT_TOKEN`.
|
||||||
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
|
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
|
||||||
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default; disable with `discord.requireMention = false`.
|
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
|
||||||
7. Optional DM allowlist: reuse `discord.allowFrom` with user ids (`1234567890` or `discord:1234567890`). Use `"*"` to allow all DMs.
|
7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs.
|
||||||
8. Optional guild allowlist: set `discord.guildAllowFrom` with `guilds` and/or `users` to gate who can invoke the bot in servers.
|
8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
|
||||||
9. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
|
9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
|
||||||
10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
|
||||||
|
11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
||||||
|
|
||||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||||
|
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||||
|
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
||||||
|
|
||||||
## Capabilities & limits
|
## Capabilities & limits
|
||||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||||
@ -41,26 +46,55 @@ Note: Discord does not provide a simple username → id lookup without extra gui
|
|||||||
discord: {
|
discord: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: "abc.123",
|
token: "abc.123",
|
||||||
allowFrom: ["123456789012345678"],
|
|
||||||
guildAllowFrom: {
|
|
||||||
guilds: ["123456789012345678"],
|
|
||||||
users: ["987654321098765432"]
|
|
||||||
},
|
|
||||||
requireMention: true,
|
|
||||||
mediaMaxMb: 8,
|
mediaMaxMb: 8,
|
||||||
historyLimit: 20,
|
enableReactions: true,
|
||||||
enableReactions: true
|
slashCommand: {
|
||||||
|
enabled: true,
|
||||||
|
name: "clawd",
|
||||||
|
sessionPrefix: "discord:slash",
|
||||||
|
ephemeral: true
|
||||||
|
},
|
||||||
|
dm: {
|
||||||
|
enabled: true,
|
||||||
|
allowFrom: ["123456789012345678", "steipete"],
|
||||||
|
groupEnabled: false,
|
||||||
|
groupChannels: ["clawd-dm"]
|
||||||
|
},
|
||||||
|
guilds: {
|
||||||
|
"123456789012345678": {
|
||||||
|
slug: "friends-of-clawd",
|
||||||
|
requireMention: false,
|
||||||
|
users: ["987654321098765432", "steipete"],
|
||||||
|
channels: {
|
||||||
|
general: { allow: true },
|
||||||
|
help: { allow: true, requireMention: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `allowFrom`: DM allowlist (user ids). Omit or set to `["*"]` to allow any DM sender.
|
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
||||||
- `guildAllowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids). When both are set, both must match.
|
- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender.
|
||||||
- `requireMention`: when `true`, messages in guild channels must mention the bot.
|
- `dm.groupEnabled`: enable group DMs (default `false`).
|
||||||
|
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
|
||||||
|
- `guilds`: per-guild rules keyed by guild id (preferred) or slug.
|
||||||
|
- `guilds.<id>.slug`: optional friendly slug used for display names.
|
||||||
|
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
|
||||||
|
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||||
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
|
- `slashCommand`: optional config for user-installed slash commands (ephemeral responses).
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||||
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
|
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
|
||||||
|
|
||||||
|
Slash command notes:
|
||||||
|
- Register a chat input command in Discord with at least one string option (e.g., `prompt`).
|
||||||
|
- The first non-empty string option is treated as the prompt.
|
||||||
|
- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules).
|
||||||
|
- Clawdis will auto-register `/clawd` (or the configured name) if it doesn't already exist.
|
||||||
|
|
||||||
## Reactions
|
## Reactions
|
||||||
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
|
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
|
||||||
- `action: "react"`
|
- `action: "react"`
|
||||||
|
|||||||
36
docs/doctor.md
Normal file
36
docs/doctor.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
summary: "Doctor command: health checks, config migrations, and repair steps"
|
||||||
|
read_when:
|
||||||
|
- Adding or modifying doctor migrations
|
||||||
|
- Introducing breaking config changes
|
||||||
|
---
|
||||||
|
# Doctor
|
||||||
|
|
||||||
|
`clawdis doctor` is the repair + migration tool for Clawdis. It runs a quick health check, audits skills, and can migrate deprecated config entries to the new schema.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
- Runs a health check and offers to restart the gateway if it looks unhealthy.
|
||||||
|
- Prints a skills status summary (eligible/missing/blocked).
|
||||||
|
- Detects deprecated config keys and offers to migrate them.
|
||||||
|
|
||||||
|
## Legacy config migrations
|
||||||
|
When the config contains deprecated keys, other commands will refuse to run and ask you to run `clawdis doctor`.
|
||||||
|
Doctor will:
|
||||||
|
- Explain which legacy keys were found.
|
||||||
|
- Show the migration it applied.
|
||||||
|
- Rewrite `~/.clawdis/clawdis.json` with the updated schema.
|
||||||
|
|
||||||
|
Current migrations:
|
||||||
|
- `routing.allowFrom` → `whatsapp.allowFrom`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdis doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to review changes before writing, open the config file first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ~/.clawdis/clawdis.json
|
||||||
|
```
|
||||||
330
docs/faq.md
Normal file
330
docs/faq.md
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
---
|
||||||
|
summary: "Frequently asked questions about Clawdis setup, configuration, and usage"
|
||||||
|
---
|
||||||
|
# FAQ 🦞
|
||||||
|
|
||||||
|
Common questions from the community. For detailed configuration, see [configuration.md](./configuration.md).
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
### Where does Clawdis store its data?
|
||||||
|
|
||||||
|
Everything lives under `~/.clawdis/`:
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `~/.clawdis/clawdis.json` | Main config (JSON5) |
|
||||||
|
| `~/.clawdis/credentials/` | WhatsApp/Telegram auth tokens |
|
||||||
|
| `~/.clawdis/sessions/` | Conversation history & state |
|
||||||
|
| `~/.clawdis/sessions/sessions.json` | Session metadata |
|
||||||
|
|
||||||
|
Your **workspace** (AGENTS.md, memory files, skills) is separate — configured via `agent.workspace` in your config (default: `~/clawd`).
|
||||||
|
|
||||||
|
### What platforms does Clawdis run on?
|
||||||
|
|
||||||
|
**macOS and Linux** are the primary targets. Anywhere Node.js 22+ runs should work in theory.
|
||||||
|
|
||||||
|
- **macOS** — Fully supported, most tested
|
||||||
|
- **Linux** — Works great, common for VPS/server deployments
|
||||||
|
- **Windows** — Should work but largely untested! You're in pioneer territory 🤠
|
||||||
|
|
||||||
|
Some features are platform-specific:
|
||||||
|
- **iMessage** — macOS only (uses `imsg` CLI)
|
||||||
|
- **Clawdis.app** — macOS native app (optional, gateway works without it)
|
||||||
|
|
||||||
|
### I'm getting "unauthorized" errors on health check
|
||||||
|
|
||||||
|
You need a config file. Run the onboarding wizard:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm clawdis onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `~/.clawdis/clawdis.json` with your API keys, workspace path, and owner phone number.
|
||||||
|
|
||||||
|
### How do I start fresh?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup first (optional)
|
||||||
|
cp -r ~/.clawdis ~/.clawdis-backup
|
||||||
|
|
||||||
|
# Remove config and credentials
|
||||||
|
rm -rf ~/.clawdis
|
||||||
|
|
||||||
|
# Re-run onboarding
|
||||||
|
pnpm clawdis onboard
|
||||||
|
pnpm clawdis login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Something's broken — how do I diagnose?
|
||||||
|
|
||||||
|
Run the doctor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm clawdis doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
It checks your config, skills status, and gateway health. It can also restart the gateway daemon if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration & Deployment
|
||||||
|
|
||||||
|
### How do I migrate Clawdis to a new machine (or VPS)?
|
||||||
|
|
||||||
|
1. **Backup on old machine:**
|
||||||
|
```bash
|
||||||
|
# Config + credentials + sessions
|
||||||
|
tar -czvf clawdis-backup.tar.gz ~/.clawdis
|
||||||
|
|
||||||
|
# Your workspace (memories, AGENTS.md, etc.)
|
||||||
|
tar -czvf workspace-backup.tar.gz ~/path/to/workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Copy to new machine:**
|
||||||
|
```bash
|
||||||
|
scp clawdis-backup.tar.gz workspace-backup.tar.gz user@new-machine:~/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Restore on new machine:**
|
||||||
|
```bash
|
||||||
|
cd ~
|
||||||
|
tar -xzvf clawdis-backup.tar.gz
|
||||||
|
tar -xzvf workspace-backup.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Install Clawdis** (Node 22+, pnpm, clone repo, `pnpm install && pnpm build`)
|
||||||
|
|
||||||
|
5. **Start gateway:**
|
||||||
|
```bash
|
||||||
|
pnpm clawdis gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** WhatsApp may notice the IP change and require re-authentication. If so, run `pnpm clawdis login` again. Stop the old instance before starting the new one to avoid conflicts.
|
||||||
|
|
||||||
|
### Can I run Clawdis in Docker?
|
||||||
|
|
||||||
|
There's no official Docker setup yet, but it works. Key considerations:
|
||||||
|
|
||||||
|
- **WhatsApp login:** QR code works in terminal — no display needed.
|
||||||
|
- **Persistence:** Mount `~/.clawdis/` and your workspace as volumes.
|
||||||
|
- **Browser automation:** Optional. If needed, install headless Chrome + Playwright deps, or connect to a remote browser via `--remote-debugging-port`.
|
||||||
|
|
||||||
|
Basic approach:
|
||||||
|
```dockerfile
|
||||||
|
FROM node:22
|
||||||
|
WORKDIR /app
|
||||||
|
# Clone, pnpm install, pnpm build
|
||||||
|
# Mount volumes for persistence
|
||||||
|
CMD ["pnpm", "clawdis", "gateway"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Can I run Clawdis headless on a VPS?
|
||||||
|
|
||||||
|
Yes! The terminal QR code login works fine over SSH. For long-running operation:
|
||||||
|
|
||||||
|
- Use `pm2`, `systemd`, or a `launchd` plist to keep the gateway running.
|
||||||
|
- Consider Tailscale for secure remote access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Instance & Contexts
|
||||||
|
|
||||||
|
### Can I run multiple Clawds (separate instances)?
|
||||||
|
|
||||||
|
The intended design is **one Clawd, one identity**. Rather than running separate instances:
|
||||||
|
|
||||||
|
- **Add skills** — Give your Clawd multiple capabilities (business + fitness + personal).
|
||||||
|
- **Use context switching** — "Hey Clawd, let's talk about fitness" within the same conversation.
|
||||||
|
- **Use groups for separation** — Create Telegram/Discord groups for different contexts; each group gets its own session.
|
||||||
|
|
||||||
|
Why? A unified assistant knows your whole context. Your fitness coach knows when you've had a stressful work week.
|
||||||
|
|
||||||
|
If you truly need full separation (different users, privacy boundaries), you'd need:
|
||||||
|
- Separate config directories
|
||||||
|
- Separate gateway ports
|
||||||
|
- Separate phone numbers for WhatsApp (one number = one account)
|
||||||
|
|
||||||
|
### Can I have separate "threads" for different topics?
|
||||||
|
|
||||||
|
Currently, sessions are per-chat:
|
||||||
|
- Each WhatsApp/Telegram DM = one session
|
||||||
|
- Each group = separate session
|
||||||
|
|
||||||
|
**Workaround:** Create multiple groups (even just you + the bot) for different contexts. Each group maintains its own session.
|
||||||
|
|
||||||
|
Feature request? Open a [GitHub discussion](https://github.com/steipete/clawdis/discussions)!
|
||||||
|
|
||||||
|
### How do groups work?
|
||||||
|
|
||||||
|
Groups get separate sessions automatically. By default, the bot requires a **mention** to respond in groups.
|
||||||
|
|
||||||
|
Per-group activation can be changed by the owner:
|
||||||
|
- `/activation mention` — respond only when mentioned (default)
|
||||||
|
- `/activation always` — respond to all messages
|
||||||
|
|
||||||
|
See [groups.md](./groups.md) for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context & Memory
|
||||||
|
|
||||||
|
### How much context can Clawdis handle?
|
||||||
|
|
||||||
|
Claude Opus has a 200k token context window, and Clawdis uses **autocompaction** — older conversation gets summarized to stay under the limit.
|
||||||
|
|
||||||
|
Practical tips:
|
||||||
|
- Keep `AGENTS.md` focused, not bloated.
|
||||||
|
- Use `/new` to reset the session when context gets stale.
|
||||||
|
- For large memory/notes collections, use search tools like `qmd` rather than loading everything.
|
||||||
|
|
||||||
|
### Where are my memory files?
|
||||||
|
|
||||||
|
In your workspace directory (configured in `agent.workspace`, default `~/clawd`). Look for:
|
||||||
|
- `memory/` — daily memory files
|
||||||
|
- `AGENTS.md` — agent instructions
|
||||||
|
- `TOOLS.md` — tool-specific notes
|
||||||
|
|
||||||
|
Check your config:
|
||||||
|
```bash
|
||||||
|
cat ~/.clawdis/clawdis.json | grep workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platforms
|
||||||
|
|
||||||
|
### Which platforms does Clawdis support?
|
||||||
|
|
||||||
|
- **WhatsApp** — Primary. Uses WhatsApp Web protocol.
|
||||||
|
- **Telegram** — Via Bot API (grammY).
|
||||||
|
- **Discord** — Bot integration.
|
||||||
|
- **iMessage** — Via `imsg` CLI (macOS only).
|
||||||
|
- **Signal** — Via `signal-cli` (see [signal.md](./signal.md)).
|
||||||
|
- **WebChat** — Browser-based chat UI.
|
||||||
|
|
||||||
|
### Can I use multiple platforms at once?
|
||||||
|
|
||||||
|
Yes! One Clawdis gateway can connect to WhatsApp, Telegram, Discord, and more simultaneously. Each platform maintains its own sessions.
|
||||||
|
|
||||||
|
### WhatsApp: Can I use two numbers?
|
||||||
|
|
||||||
|
One WhatsApp account = one phone number = one gateway connection. For a second number, you'd need a second gateway instance with a separate config directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skills & Tools
|
||||||
|
|
||||||
|
### How do I add new skills?
|
||||||
|
|
||||||
|
Skills are auto-discovered from your workspace's `skills/` folder. After adding new skills:
|
||||||
|
|
||||||
|
1. Send `/reset` (or `/new`) in chat to start a new session
|
||||||
|
2. The new skills will be available
|
||||||
|
|
||||||
|
No gateway restart needed!
|
||||||
|
|
||||||
|
### How do I run commands on other machines?
|
||||||
|
|
||||||
|
Use **Tailscale** to create a secure network between your machines:
|
||||||
|
|
||||||
|
1. Install Tailscale on all machines
|
||||||
|
2. Each gets a stable IP (like `100.x.x.x`)
|
||||||
|
3. SSH just works: `ssh user@100.x.x.x "command"`
|
||||||
|
|
||||||
|
For deeper integration, look into **Clawdis nodes** — pair remote machines with your gateway for camera/screen/automation access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build errors (TypeScript)
|
||||||
|
|
||||||
|
If you hit build errors on `main`:
|
||||||
|
|
||||||
|
1. Pull latest: `git pull origin main && pnpm install`
|
||||||
|
2. Try `pnpm clawdis doctor`
|
||||||
|
3. Check [GitHub issues](https://github.com/steipete/clawdis/issues) or Discord
|
||||||
|
4. Temporary workaround: checkout an older commit
|
||||||
|
|
||||||
|
### WhatsApp logged me out
|
||||||
|
|
||||||
|
WhatsApp sometimes disconnects on IP changes or after updates. Re-authenticate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm clawdis login
|
||||||
|
```
|
||||||
|
|
||||||
|
Scan the QR code and you're back.
|
||||||
|
|
||||||
|
### Gateway won't start
|
||||||
|
|
||||||
|
Check logs:
|
||||||
|
```bash
|
||||||
|
cat /tmp/clawdis/clawdis-$(date +%Y-%m-%d).log
|
||||||
|
```
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
- Port already in use (change with `--port`)
|
||||||
|
- Missing API keys in config
|
||||||
|
- Invalid config syntax (remember it's JSON5, but still check for errors)
|
||||||
|
|
||||||
|
**Pro tip:** Use Codex to debug:
|
||||||
|
```bash
|
||||||
|
cd ~/path/to/clawdis
|
||||||
|
codex --full-auto "debug why clawdis gateway won't start"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processes keep restarting after I kill them (Linux)
|
||||||
|
|
||||||
|
Something is supervising them. Check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# systemd?
|
||||||
|
systemctl list-units | grep -i clawdis
|
||||||
|
sudo systemctl stop clawdis
|
||||||
|
|
||||||
|
# pm2?
|
||||||
|
pm2 list
|
||||||
|
pm2 delete all
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop the supervisor first, then the processes.
|
||||||
|
|
||||||
|
### Clean uninstall (start fresh)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop processes
|
||||||
|
pkill -f "clawdis"
|
||||||
|
|
||||||
|
# If using systemd
|
||||||
|
sudo systemctl stop clawdis
|
||||||
|
sudo systemctl disable clawdis
|
||||||
|
|
||||||
|
# Remove data
|
||||||
|
rm -rf ~/.clawdis
|
||||||
|
|
||||||
|
# Remove repo and re-clone
|
||||||
|
rm -rf ~/clawdis
|
||||||
|
git clone https://github.com/steipete/clawdis.git
|
||||||
|
cd clawdis && pnpm install && pnpm build
|
||||||
|
pnpm clawdis onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chat Commands
|
||||||
|
|
||||||
|
Quick reference (send these in chat):
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `/status` | Health + session info |
|
||||||
|
| `/new` or `/reset` | Reset the session |
|
||||||
|
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
|
||||||
|
| `/verbose on\|off` | Toggle verbose mode |
|
||||||
|
| `/activation mention\|always` | Group activation (owner-only) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Still stuck? Ask in [Discord](https://discord.gg/qkhbAGHRBT) or open a [GitHub discussion](https://github.com/steipete/clawdis/discussions).* 🦞
|
||||||
@ -17,7 +17,7 @@ Updated: 2025-12-07
|
|||||||
- **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`.
|
- **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`.
|
||||||
- **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
- **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls).
|
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls).
|
||||||
- **Sessions:** direct chats map to `main`; groups map to `group:<chatId>`; replies route back to the same surface.
|
- **Sessions:** direct chats map to `main`; groups map to `telegram:group:<chatId>`; replies route back to the same surface.
|
||||||
- **Config knobs:** `telegram.botToken`, `requireMention`, `allowFrom`, `mediaMaxMb`, `proxy`, `webhookSecret`, `webhookUrl`.
|
- **Config knobs:** `telegram.botToken`, `requireMention`, `allowFrom`, `mediaMaxMb`, `proxy`, `webhookSecret`, `webhookUrl`.
|
||||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,8 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that
|
|||||||
|
|
||||||
## What’s implemented (2025-12-03)
|
## What’s implemented (2025-12-03)
|
||||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Activation is controlled per group (command or UI), not via config.
|
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Activation is controlled per group (command or UI), not via config.
|
||||||
- Group allowlist bypass: we still enforce `routing.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
- Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
||||||
- Per-group sessions: session keys look like `group:<jid>` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
- Per-group sessions: session keys look like `whatsapp:group:<jid>` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads.
|
||||||
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
|
- Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`.
|
||||||
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
|
- Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking.
|
||||||
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
|
- Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger.
|
||||||
@ -45,7 +45,7 @@ Use the group chat command:
|
|||||||
- `/activation mention`
|
- `/activation mention`
|
||||||
- `/activation always`
|
- `/activation always`
|
||||||
|
|
||||||
Only the owner number (from `routing.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode.
|
Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own E.164 when unset) can change this. `/status` in the group shows the current activation mode.
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
1) Add Clawd UK (`+447700900123`) to the group.
|
1) Add Clawd UK (`+447700900123`) to the group.
|
||||||
@ -63,4 +63,4 @@ Only the owner number (from `routing.allowFrom`, defaulting to the bot’s own E
|
|||||||
## Known considerations
|
## Known considerations
|
||||||
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
|
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
|
||||||
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
|
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
|
||||||
- Session store entries will appear as `group:<jid>` in the session store (`~/.clawdis/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
|
- Session store entries will appear as `whatsapp:group:<jid>` in the session store (`~/.clawdis/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
|
||||||
|
|||||||
@ -8,10 +8,14 @@ read_when:
|
|||||||
Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage.
|
Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage.
|
||||||
|
|
||||||
## Session keys
|
## Session keys
|
||||||
- Group sessions use `group:<id>` in `ctx.From`.
|
- Group sessions use `surface:group:<id>` session keys (rooms/channels use `surface:channel:<id>`).
|
||||||
- Direct chats use the main session (or per-sender if configured).
|
- Direct chats use the main session (or per-sender if configured).
|
||||||
- Heartbeats are skipped for group sessions.
|
- Heartbeats are skipped for group sessions.
|
||||||
|
|
||||||
|
## Display labels
|
||||||
|
- UI labels use `displayName` when available, formatted as `surface:<token>`.
|
||||||
|
- `#room` is reserved for rooms/channels; group chats use `g-<slug>` (lowercase, spaces -> `-`, keep `#@+._-`).
|
||||||
|
|
||||||
## Mention gating (default)
|
## Mention gating (default)
|
||||||
Group messages require a mention unless overridden per group.
|
Group messages require a mention unless overridden per group.
|
||||||
|
|
||||||
@ -36,7 +40,7 @@ Group owners can toggle per-group activation:
|
|||||||
- `/activation mention`
|
- `/activation mention`
|
||||||
- `/activation always`
|
- `/activation always`
|
||||||
|
|
||||||
Owner is determined by `routing.allowFrom` (or the bot’s default identity when unset).
|
Owner is determined by `whatsapp.allowFrom` (or the bot’s self E.164 when unset). Other surfaces currently ignore `/activation`.
|
||||||
|
|
||||||
## Context fields
|
## Context fields
|
||||||
Group inbound payloads set:
|
Group inbound payloads set:
|
||||||
|
|||||||
@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
|
|||||||
## When something fails
|
## When something fails
|
||||||
- `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`.
|
- `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`.
|
||||||
- Gateway unreachable → start it: `clawdis gateway --port 18789` (use `--force` if the port is busy).
|
- Gateway unreachable → start it: `clawdis gateway --port 18789` (use `--force` if the port is busy).
|
||||||
- No inbound messages → confirm linked phone is online and the sender is allowed (`routing.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat`).
|
- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat`).
|
||||||
|
|
||||||
## Dedicated "health" command
|
## Dedicated "health" command
|
||||||
`clawdis health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout <ms>` to override the 10s default.
|
`clawdis health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout <ms>` to override the 10s default.
|
||||||
|
|||||||
@ -100,22 +100,21 @@ clawdis send --to +15555550123 --message "Hello from CLAWDIS"
|
|||||||
Config lives at `~/.clawdis/clawdis.json`.
|
Config lives at `~/.clawdis/clawdis.json`.
|
||||||
|
|
||||||
- If you **do nothing**, CLAWDIS uses the bundled Pi binary in RPC mode with per-sender sessions.
|
- If you **do nothing**, CLAWDIS uses the bundled Pi binary in RPC mode with per-sender sessions.
|
||||||
- If you want to lock it down, start with `routing.allowFrom` and (for groups) mention rules.
|
- If you want to lock it down, start with `whatsapp.allowFrom` and (for groups) mention rules.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
routing: {
|
whatsapp: { allowFrom: ["+15555550123"] },
|
||||||
allowFrom: ["+15555550123"],
|
routing: { groupChat: { requireMention: true, mentionPatterns: ["@clawd"] } }
|
||||||
groupChat: { requireMention: true, mentionPatterns: ["@clawd"] }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
- Start here:
|
- Start here:
|
||||||
|
- [FAQ](./faq.md) ← *common questions answered*
|
||||||
- [Configuration](./configuration.md)
|
- [Configuration](./configuration.md)
|
||||||
- [Nix mode](./nix.md)
|
- [Nix mode](./nix.md)
|
||||||
- [Clawd personal assistant setup](./clawd.md)
|
- [Clawd personal assistant setup](./clawd.md)
|
||||||
|
|||||||
@ -22,6 +22,10 @@ First question: where does the **Gateway** run?
|
|||||||
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdis token store locally.
|
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdis token store locally.
|
||||||
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
|
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
|
||||||
|
|
||||||
|
Gateway auth tip:
|
||||||
|
- If you only use Clawdis on this Mac (loopback gateway), keep auth **Off**.
|
||||||
|
- Use **Token** for multi-machine access or non-loopback binds.
|
||||||
|
|
||||||
Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user).
|
Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user).
|
||||||
|
|
||||||
## 2) Local-only: Connect Claude (Anthropic OAuth)
|
## 2) Local-only: Connect Claude (Anthropic OAuth)
|
||||||
|
|||||||
@ -42,7 +42,7 @@ This is social engineering 101. Create distrust, encourage snooping.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"routing": {
|
"whatsapp": {
|
||||||
"allowFrom": ["+15555550123"]
|
"allowFrom": ["+15555550123"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,12 +18,14 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
|
|||||||
- Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`).
|
- Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`).
|
||||||
- Transcripts: `~/.clawdis/sessions/<SessionId>.jsonl` (one file per session id).
|
- Transcripts: `~/.clawdis/sessions/<SessionId>.jsonl` (one file per session id).
|
||||||
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
||||||
|
- Group entries may include `displayName`, `surface`, `subject`, `room`, and `space` to label sessions in UIs.
|
||||||
- Clawdis does **not** read legacy Pi/Tau session folders.
|
- Clawdis does **not** read legacy Pi/Tau session folders.
|
||||||
|
|
||||||
## Mapping transports → session keys
|
## Mapping transports → session keys
|
||||||
- Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context.
|
- Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context.
|
||||||
- Multiple phone numbers can map to that same key; they act as transports into the same conversation.
|
- Multiple phone numbers can map to that same key; they act as transports into the same conversation.
|
||||||
- Group chats still isolate state with `group:<jid>` keys; do not reuse the primary key for groups.
|
- Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups. (Discord display names show `discord:<guildSlug>#<channelSlug>`.)
|
||||||
|
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
|
||||||
|
|
||||||
## Lifecyle
|
## Lifecyle
|
||||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
||||||
|
|||||||
8
docs/sessions.md
Normal file
8
docs/sessions.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
summary: "Alias for session management docs"
|
||||||
|
read_when:
|
||||||
|
- You looked for docs/sessions.md; canonical doc lives in docs/session.md
|
||||||
|
---
|
||||||
|
# Sessions
|
||||||
|
|
||||||
|
Canonical session management docs live in `docs/session.md`.
|
||||||
@ -30,6 +30,11 @@ You can still run Clawdis on your own Signal account if your goal is “respond
|
|||||||
|
|
||||||
## Quickstart (bot number)
|
## Quickstart (bot number)
|
||||||
1) Install `signal-cli` (keep Java installed).
|
1) Install `signal-cli` (keep Java installed).
|
||||||
|
- If you use the CLI wizard, it can auto-install to `~/.clawdis/tools/signal-cli/...`.
|
||||||
|
- If you want a pinned version (example: `v0.13.22`), install manually:
|
||||||
|
- Download the release asset for your platform from GitHub (tag `v0.13.22`).
|
||||||
|
- Extract it somewhere stable (example: `~/.clawdis/tools/signal-cli/0.13.22/`).
|
||||||
|
- Set `signal.cliPath` to the extracted `signal-cli` binary path.
|
||||||
2) Link the bot account as a device:
|
2) Link the bot account as a device:
|
||||||
- Run: `signal-cli link -n "Clawdis"`
|
- Run: `signal-cli link -n "Clawdis"`
|
||||||
- Scan QR in Signal: Settings → Linked Devices → Link New Device
|
- Scan QR in Signal: Settings → Linked Devices → Link New Device
|
||||||
@ -55,6 +60,15 @@ You can still run Clawdis on your own Signal account if your goal is “respond
|
|||||||
- Expect `signal.probe.ok=true` and `signal.probe.version`.
|
- Expect `signal.probe.ok=true` and `signal.probe.version`.
|
||||||
5) DM the bot number from your phone; Clawdis replies.
|
5) DM the bot number from your phone; Clawdis replies.
|
||||||
|
|
||||||
|
## “Do I need a separate number?”
|
||||||
|
- If you want “I text her and she texts me back”, yes: **use a separate Signal account/number for the bot**.
|
||||||
|
- Your personal account can run `signal-cli`, but you can’t self-chat (Signal loop protection; Clawdis ignores sender==account).
|
||||||
|
|
||||||
|
If you have a second phone:
|
||||||
|
- Create/activate the bot number on that phone.
|
||||||
|
- Run `signal-cli link -n "Clawdis"` on your Mac, scan the QR on the bot phone.
|
||||||
|
- Put your personal number in `signal.allowFrom`, then DM the bot number from your personal phone.
|
||||||
|
|
||||||
## Endpoints (daemon --http)
|
## Endpoints (daemon --http)
|
||||||
- `POST /api/v1/rpc` JSON-RPC request (single or batch).
|
- `POST /api/v1/rpc` JSON-RPC request (single or batch).
|
||||||
- `GET /api/v1/events` SSE stream of `receive` notifications.
|
- `GET /api/v1/events` SSE stream of `receive` notifications.
|
||||||
@ -65,6 +79,10 @@ You can still run Clawdis on your own Signal account if your goal is “respond
|
|||||||
- Include `params.account` (E164) on JSON-RPC calls.
|
- Include `params.account` (E164) on JSON-RPC calls.
|
||||||
- SSE `?account=+E164` filters events; no param = all accounts.
|
- SSE `?account=+E164` filters events; no param = all accounts.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- Gateway log coloring: `signal-cli: ...` lines are classified by severity; red means “treat this as an error”.
|
||||||
|
- `Failed to initialize HTTP Server` typically means the daemon can’t bind the HTTP port (already in use). Stop the other daemon or change `signal.httpPort`.
|
||||||
|
|
||||||
## Minimal RPC surface
|
## Minimal RPC surface
|
||||||
- `send` (recipient/groupId/username, message, attachments).
|
- `send` (recipient/groupId/username, message, attachments).
|
||||||
- `listGroups` (map group IDs).
|
- `listGroups` (map group IDs).
|
||||||
@ -73,7 +91,7 @@ You can still run Clawdis on your own Signal account if your goal is “respond
|
|||||||
|
|
||||||
## Addressing (send targets)
|
## Addressing (send targets)
|
||||||
- Direct: `signal:+15551234567` (or plain `+15551234567`)
|
- Direct: `signal:+15551234567` (or plain `+15551234567`)
|
||||||
- Groups: `group:<groupId>`
|
- Groups: `signal:group:<groupId>`
|
||||||
- Usernames: `username:<name>` / `u:<name>`
|
- Usernames: `username:<name>` / `u:<name>`
|
||||||
|
|
||||||
## Process plan (Clawdis adapter)
|
## Process plan (Clawdis adapter)
|
||||||
|
|||||||
@ -11,7 +11,7 @@ Goal: make replies deterministic per channel while keeping one shared context fo
|
|||||||
|
|
||||||
- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `discord`, `imessage`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose.
|
- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `discord`, `imessage`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose.
|
||||||
- **Reply context:** inbound replies include `ReplyToId`, `ReplyToBody`, and `ReplyToSender`, and the quoted context is appended to `Body` as a `[Replying to ...]` block.
|
- **Reply context:** inbound replies include `ReplyToId`, `ReplyToBody`, and `ReplyToSender`, and the quoted context is appended to `Body` as a `[Replying to ...]` block.
|
||||||
- **Canonical direct session:** All direct chats collapse into the single `main` session by default (no config needed). Groups stay `group:<jid>`, so they remain isolated.
|
- **Canonical direct session:** All direct chats collapse into the single `main` session by default (no config needed). Groups stay `surface:group:<id>` (rooms: `surface:channel:<id>`), so they remain isolated.
|
||||||
- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdis/sessions/<SessionId>.jsonl`.
|
- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdis/sessions/<SessionId>.jsonl`.
|
||||||
- **WebChat:** Always attaches to `main`, loads the full session transcript so desktop reflects cross-surface history, and writes new turns back to the same session.
|
- **WebChat:** Always attaches to `main`, loads the full session transcript so desktop reflects cross-surface history, and writes new turns back to the same session.
|
||||||
- **Implementation hints:**
|
- **Implementation hints:**
|
||||||
|
|||||||
@ -11,20 +11,21 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Let you talk to Clawdis via a Telegram bot in DMs and groups.
|
- Let you talk to Clawdis via a Telegram bot in DMs and groups.
|
||||||
- Share the same `main` session used by WhatsApp/WebChat; groups stay isolated as `group:<chatId>`.
|
- Share the same `main` session used by WhatsApp/WebChat; groups stay isolated as `telegram:group:<chatId>`.
|
||||||
- Keep transport routing deterministic: replies always go back to the surface they arrived on.
|
- Keep transport routing deterministic: replies always go back to the surface they arrived on.
|
||||||
|
|
||||||
## How it will work (Bot API)
|
## How it will work (Bot API)
|
||||||
1) Create a bot with @BotFather and grab the token.
|
1) Create a bot with @BotFather and grab the token.
|
||||||
2) Configure Clawdis with `TELEGRAM_BOT_TOKEN` (or `telegram.botToken` in `~/.clawdis/clawdis.json`).
|
2) Configure Clawdis with `TELEGRAM_BOT_TOKEN` (or `telegram.botToken` in `~/.clawdis/clawdis.json`).
|
||||||
3) Run the gateway; it auto-starts Telegram when the bot token is set (unless `telegram.enabled = false`).
|
3) Run the gateway; it auto-starts Telegram only when a `telegram` config section exists **and** a bot token is set (unless `telegram.enabled = false`).
|
||||||
|
- If you prefer env vars, still add `telegram: { enabled: true }` to `~/.clawdis/clawdis.json` and set `TELEGRAM_BOT_TOKEN`.
|
||||||
- **Long-polling** is the default.
|
- **Long-polling** is the default.
|
||||||
- **Webhook mode** is enabled by setting `telegram.webhookUrl` (optionally `telegram.webhookSecret` / `telegram.webhookPath`).
|
- **Webhook mode** is enabled by setting `telegram.webhookUrl` (optionally `telegram.webhookSecret` / `telegram.webhookPath`).
|
||||||
- The webhook listener currently binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default.
|
- The webhook listener currently binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default.
|
||||||
- If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`.
|
- If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`.
|
||||||
4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config).
|
4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config).
|
||||||
5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `group:<chatId>` and require mention/command to trigger replies.
|
5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:<chatId>` and require mention/command to trigger replies.
|
||||||
6) Optional allowlist: reuse `routing.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`).
|
6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`).
|
||||||
|
|
||||||
## Capabilities & limits (Bot API)
|
## Capabilities & limits (Bot API)
|
||||||
- Sees only messages sent after it’s added to a chat; no pre-history access.
|
- Sees only messages sent after it’s added to a chat; no pre-history access.
|
||||||
|
|||||||
@ -22,9 +22,9 @@ The agent was interrupted mid-response.
|
|||||||
|
|
||||||
### Messages Not Triggering
|
### Messages Not Triggering
|
||||||
|
|
||||||
**Check 1:** Is the sender in `routing.allowFrom`?
|
**Check 1:** Is the sender in `whatsapp.allowFrom`?
|
||||||
```bash
|
```bash
|
||||||
cat ~/.clawdis/clawdis.json | jq '.routing.allowFrom'
|
cat ~/.clawdis/clawdis.json | jq '.whatsapp.allowFrom'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check 2:** For group chats, is mention required?
|
**Check 2:** For group chats, is mention required?
|
||||||
|
|||||||
@ -31,8 +31,8 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
- Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts.
|
- Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts.
|
||||||
- Status/broadcast chats are ignored.
|
- Status/broadcast chats are ignored.
|
||||||
- Direct chats use E.164; groups use group JID.
|
- Direct chats use E.164; groups use group JID.
|
||||||
- **Allowlist**: `routing.allowFrom` enforced for direct chats only.
|
- **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only.
|
||||||
- If `routing.allowFrom` is empty, default allowlist = self number (self-chat mode).
|
- If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode).
|
||||||
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
|
- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
|
||||||
- Read receipts sent for non-self-chat DMs.
|
- Read receipts sent for non-self-chat DMs.
|
||||||
|
|
||||||
@ -52,12 +52,12 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
- `<media:image|video|audio|document|sticker>`
|
- `<media:image|video|audio|document|sticker>`
|
||||||
|
|
||||||
## Groups
|
## Groups
|
||||||
- Groups map to `group:<jid>` sessions.
|
- Groups map to `whatsapp:group:<jid>` sessions.
|
||||||
- Activation modes:
|
- Activation modes:
|
||||||
- `mention` (default): requires @mention or regex match.
|
- `mention` (default): requires @mention or regex match.
|
||||||
- `always`: always triggers.
|
- `always`: always triggers.
|
||||||
- `/activation mention|always` is owner-only.
|
- `/activation mention|always` is owner-only.
|
||||||
- Owner = `routing.allowFrom` (or self E.164 if unset).
|
- Owner = `whatsapp.allowFrom` (or self E.164 if unset).
|
||||||
- **History injection**:
|
- **History injection**:
|
||||||
- Recent messages (default 50) inserted under:
|
- Recent messages (default 50) inserted under:
|
||||||
`[Chat messages since your last reply - for context]`
|
`[Chat messages since your last reply - for context]`
|
||||||
@ -98,7 +98,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
- Logged-out => stop and require re-link.
|
- Logged-out => stop and require re-link.
|
||||||
|
|
||||||
## Config quick map
|
## Config quick map
|
||||||
- `routing.allowFrom` (DM allowlist).
|
- `whatsapp.allowFrom` (DM allowlist).
|
||||||
- `routing.groupChat.mentionPatterns`
|
- `routing.groupChat.mentionPatterns`
|
||||||
- `routing.groupChat.historyLimit`
|
- `routing.groupChat.historyLimit`
|
||||||
- `messages.messagePrefix` (inbound prefix)
|
- `messages.messagePrefix` (inbound prefix)
|
||||||
|
|||||||
@ -58,6 +58,7 @@ It does **not** install or change anything on the remote host.
|
|||||||
|
|
||||||
4) **Gateway**
|
4) **Gateway**
|
||||||
- Port, bind, auth mode, tailscale exposure.
|
- Port, bind, auth mode, tailscale exposure.
|
||||||
|
- Auth recommendation: keep **Off** for single-machine loopback setups. Use **Token** for multi-machine access or non-loopback binds.
|
||||||
- Non‑loopback binds require auth.
|
- Non‑loopback binds require auth.
|
||||||
|
|
||||||
5) **Providers**
|
5) **Providers**
|
||||||
|
|||||||
101
skills/local-places/SERVER_README.md
Normal file
101
skills/local-places/SERVER_README.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# Local Places
|
||||||
|
|
||||||
|
This repo is a fusion of two pieces:
|
||||||
|
|
||||||
|
- A FastAPI server that exposes endpoints for searching and resolving places via the Google Maps Places API.
|
||||||
|
- A companion agent skill that explains how to use the API and can call it to find places efficiently.
|
||||||
|
|
||||||
|
Together, the skill and server let an agent turn natural-language place queries into structured results quickly.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# copy skill definition into the relevant folder (where the agent looks for it)
|
||||||
|
# then run the server
|
||||||
|
|
||||||
|
uv venv
|
||||||
|
uv pip install -e ".[dev]"
|
||||||
|
uv run --env-file .env uvicorn local_places.main:app --host 0.0.0.0 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the API docs at http://127.0.0.1:8000/docs.
|
||||||
|
|
||||||
|
## Places API
|
||||||
|
|
||||||
|
Set the Google Places API key before running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOOGLE_PLACES_API_KEY="your-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- `POST /places/search` (free-text query + filters)
|
||||||
|
- `GET /places/{place_id}` (place details)
|
||||||
|
- `POST /locations/resolve` (resolve a user-provided location string)
|
||||||
|
|
||||||
|
Example search request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "italian restaurant",
|
||||||
|
"filters": {
|
||||||
|
"types": ["restaurant"],
|
||||||
|
"open_now": true,
|
||||||
|
"min_rating": 4.0,
|
||||||
|
"price_levels": [1, 2]
|
||||||
|
},
|
||||||
|
"limit": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `filters.types` supports a single type (mapped to Google `includedType`).
|
||||||
|
|
||||||
|
Example search request (curl):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/places/search \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"query": "italian restaurant",
|
||||||
|
"location_bias": {
|
||||||
|
"lat": 40.8065,
|
||||||
|
"lng": -73.9719,
|
||||||
|
"radius_m": 3000
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"types": ["restaurant"],
|
||||||
|
"open_now": true,
|
||||||
|
"min_rating": 4.0,
|
||||||
|
"price_levels": [1, 2, 3]
|
||||||
|
},
|
||||||
|
"limit": 10
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Example resolve request (curl):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/locations/resolve \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"location_text": "Riverside Park, New York",
|
||||||
|
"limit": 5
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAPI
|
||||||
|
|
||||||
|
Generate the OpenAPI schema:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/generate_openapi.py
|
||||||
|
```
|
||||||
89
skills/local-places/SKILL.md
Normal file
89
skills/local-places/SKILL.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: local-places
|
||||||
|
description: Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
|
||||||
|
homepage: https://github.com/Hyaxia/local_places
|
||||||
|
metadata: {"clawdis":{"emoji":"📍","requires":{"bins":["uv"],"env":["GOOGLE_PLACES_API_KEY"]},"primaryEnv":"GOOGLE_PLACES_API_KEY"}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Local Places
|
||||||
|
|
||||||
|
Search for nearby places using a local Google Places API proxy. Two-step flow: resolve location first, then search.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd {baseDir}
|
||||||
|
echo "GOOGLE_PLACES_API_KEY=your-key" > .env
|
||||||
|
uv venv && uv pip install -e ".[dev]"
|
||||||
|
uv run --env-file .env uvicorn local_places.main:app --host 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `GOOGLE_PLACES_API_KEY` in `.env` or environment.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. **Check server:** `curl http://127.0.0.1:8000/ping`
|
||||||
|
|
||||||
|
2. **Resolve location:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/locations/resolve \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"location_text": "Soho, London", "limit": 5}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Search places:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/places/search \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"query": "coffee shop",
|
||||||
|
"location_bias": {"lat": 51.5137, "lng": -0.1366, "radius_m": 1000},
|
||||||
|
"filters": {"open_now": true, "min_rating": 4.0},
|
||||||
|
"limit": 10
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Get details:**
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/places/{place_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conversation Flow
|
||||||
|
|
||||||
|
1. If user says "near me" or gives vague location → resolve it first
|
||||||
|
2. If multiple results → show numbered list, ask user to pick
|
||||||
|
3. Ask for preferences: type, open now, rating, price level
|
||||||
|
4. Search with `location_bias` from chosen location
|
||||||
|
5. Present results with name, rating, address, open status
|
||||||
|
6. Offer to fetch details or refine search
|
||||||
|
|
||||||
|
## Filter Constraints
|
||||||
|
|
||||||
|
- `filters.types`: exactly ONE type (e.g., "restaurant", "cafe", "gym")
|
||||||
|
- `filters.price_levels`: integers 0-4 (0=free, 4=very expensive)
|
||||||
|
- `filters.min_rating`: 0-5 in 0.5 increments
|
||||||
|
- `filters.open_now`: boolean
|
||||||
|
- `limit`: 1-20 for search, 1-10 for resolve
|
||||||
|
- `location_bias.radius_m`: must be > 0
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"place_id": "ChIJ...",
|
||||||
|
"name": "Coffee Shop",
|
||||||
|
"address": "123 Main St",
|
||||||
|
"location": {"lat": 51.5, "lng": -0.1},
|
||||||
|
"rating": 4.6,
|
||||||
|
"price_level": 2,
|
||||||
|
"types": ["cafe", "food"],
|
||||||
|
"open_now": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"next_page_token": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `next_page_token` as `page_token` in next request for more results.
|
||||||
27
skills/local-places/pyproject.toml
Normal file
27
skills/local-places/pyproject.toml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
[project]
|
||||||
|
name = "my-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "FastAPI server"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.110.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"uvicorn[standard]>=0.29.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/local_places"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "-q"
|
||||||
|
testpaths = ["tests"]
|
||||||
2
skills/local-places/src/local_places/__init__.py
Normal file
2
skills/local-places/src/local_places/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
__all__ = ["__version__"]
|
||||||
|
__version__ = "0.1.0"
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
314
skills/local-places/src/local_places/google_places.py
Normal file
314
skills/local-places/src/local_places/google_places.py
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from local_places.schemas import (
|
||||||
|
LatLng,
|
||||||
|
LocationResolveRequest,
|
||||||
|
LocationResolveResponse,
|
||||||
|
PlaceDetails,
|
||||||
|
PlaceSummary,
|
||||||
|
ResolvedLocation,
|
||||||
|
SearchRequest,
|
||||||
|
SearchResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
GOOGLE_PLACES_BASE_URL = os.getenv(
|
||||||
|
"GOOGLE_PLACES_BASE_URL", "https://places.googleapis.com/v1"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("local_places.google_places")
|
||||||
|
|
||||||
|
_PRICE_LEVEL_TO_ENUM = {
|
||||||
|
0: "PRICE_LEVEL_FREE",
|
||||||
|
1: "PRICE_LEVEL_INEXPENSIVE",
|
||||||
|
2: "PRICE_LEVEL_MODERATE",
|
||||||
|
3: "PRICE_LEVEL_EXPENSIVE",
|
||||||
|
4: "PRICE_LEVEL_VERY_EXPENSIVE",
|
||||||
|
}
|
||||||
|
_ENUM_TO_PRICE_LEVEL = {value: key for key, value in _PRICE_LEVEL_TO_ENUM.items()}
|
||||||
|
|
||||||
|
_SEARCH_FIELD_MASK = (
|
||||||
|
"places.id,"
|
||||||
|
"places.displayName,"
|
||||||
|
"places.formattedAddress,"
|
||||||
|
"places.location,"
|
||||||
|
"places.rating,"
|
||||||
|
"places.priceLevel,"
|
||||||
|
"places.types,"
|
||||||
|
"places.currentOpeningHours,"
|
||||||
|
"nextPageToken"
|
||||||
|
)
|
||||||
|
|
||||||
|
_DETAILS_FIELD_MASK = (
|
||||||
|
"id,"
|
||||||
|
"displayName,"
|
||||||
|
"formattedAddress,"
|
||||||
|
"location,"
|
||||||
|
"rating,"
|
||||||
|
"priceLevel,"
|
||||||
|
"types,"
|
||||||
|
"regularOpeningHours,"
|
||||||
|
"currentOpeningHours,"
|
||||||
|
"nationalPhoneNumber,"
|
||||||
|
"websiteUri"
|
||||||
|
)
|
||||||
|
|
||||||
|
_RESOLVE_FIELD_MASK = (
|
||||||
|
"places.id,"
|
||||||
|
"places.displayName,"
|
||||||
|
"places.formattedAddress,"
|
||||||
|
"places.location,"
|
||||||
|
"places.types"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _GoogleResponse:
|
||||||
|
def __init__(self, response: httpx.Response):
|
||||||
|
self.status_code = response.status_code
|
||||||
|
self._response = response
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
return self._response.json()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return self._response.text
|
||||||
|
|
||||||
|
|
||||||
|
def _api_headers(field_mask: str) -> dict[str, str]:
|
||||||
|
api_key = os.getenv("GOOGLE_PLACES_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="GOOGLE_PLACES_API_KEY is not set.",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Goog-Api-Key": api_key,
|
||||||
|
"X-Goog-FieldMask": field_mask,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
method: str, url: str, payload: dict[str, Any] | None, field_mask: str
|
||||||
|
) -> _GoogleResponse:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
response = client.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
headers=_api_headers(field_mask),
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail="Google Places API unavailable.") from exc
|
||||||
|
|
||||||
|
return _GoogleResponse(response)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_text_query(request: SearchRequest) -> str:
|
||||||
|
keyword = request.filters.keyword if request.filters else None
|
||||||
|
if keyword:
|
||||||
|
return f"{request.query} {keyword}".strip()
|
||||||
|
return request.query
|
||||||
|
|
||||||
|
|
||||||
|
def _build_search_body(request: SearchRequest) -> dict[str, Any]:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"textQuery": _build_text_query(request),
|
||||||
|
"pageSize": request.limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.page_token:
|
||||||
|
body["pageToken"] = request.page_token
|
||||||
|
|
||||||
|
if request.location_bias:
|
||||||
|
body["locationBias"] = {
|
||||||
|
"circle": {
|
||||||
|
"center": {
|
||||||
|
"latitude": request.location_bias.lat,
|
||||||
|
"longitude": request.location_bias.lng,
|
||||||
|
},
|
||||||
|
"radius": request.location_bias.radius_m,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.filters:
|
||||||
|
filters = request.filters
|
||||||
|
if filters.types:
|
||||||
|
body["includedType"] = filters.types[0]
|
||||||
|
if filters.open_now is not None:
|
||||||
|
body["openNow"] = filters.open_now
|
||||||
|
if filters.min_rating is not None:
|
||||||
|
body["minRating"] = filters.min_rating
|
||||||
|
if filters.price_levels:
|
||||||
|
body["priceLevels"] = [
|
||||||
|
_PRICE_LEVEL_TO_ENUM[level] for level in filters.price_levels
|
||||||
|
]
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lat_lng(raw: dict[str, Any] | None) -> LatLng | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
latitude = raw.get("latitude")
|
||||||
|
longitude = raw.get("longitude")
|
||||||
|
if latitude is None or longitude is None:
|
||||||
|
return None
|
||||||
|
return LatLng(lat=latitude, lng=longitude)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_display_name(raw: dict[str, Any] | None) -> str | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return raw.get("text")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_open_now(raw: dict[str, Any] | None) -> bool | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return raw.get("openNow")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hours(raw: dict[str, Any] | None) -> list[str] | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return raw.get("weekdayDescriptions")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_price_level(raw: str | None) -> int | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return _ENUM_TO_PRICE_LEVEL.get(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def search_places(request: SearchRequest) -> SearchResponse:
|
||||||
|
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
|
||||||
|
response = _request("POST", url, _build_search_body(request), _SEARCH_FIELD_MASK)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.error(
|
||||||
|
"Google Places API error %s. response=%s",
|
||||||
|
response.status_code,
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Google Places API error ({response.status_code}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Google Places API returned invalid JSON. response=%s",
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
||||||
|
|
||||||
|
places = payload.get("places", [])
|
||||||
|
results = []
|
||||||
|
for place in places:
|
||||||
|
results.append(
|
||||||
|
PlaceSummary(
|
||||||
|
place_id=place.get("id", ""),
|
||||||
|
name=_parse_display_name(place.get("displayName")),
|
||||||
|
address=place.get("formattedAddress"),
|
||||||
|
location=_parse_lat_lng(place.get("location")),
|
||||||
|
rating=place.get("rating"),
|
||||||
|
price_level=_parse_price_level(place.get("priceLevel")),
|
||||||
|
types=place.get("types"),
|
||||||
|
open_now=_parse_open_now(place.get("currentOpeningHours")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResponse(
|
||||||
|
results=results,
|
||||||
|
next_page_token=payload.get("nextPageToken"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_place_details(place_id: str) -> PlaceDetails:
|
||||||
|
url = f"{GOOGLE_PLACES_BASE_URL}/places/{place_id}"
|
||||||
|
response = _request("GET", url, None, _DETAILS_FIELD_MASK)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.error(
|
||||||
|
"Google Places API error %s. response=%s",
|
||||||
|
response.status_code,
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Google Places API error ({response.status_code}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Google Places API returned invalid JSON. response=%s",
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
||||||
|
|
||||||
|
return PlaceDetails(
|
||||||
|
place_id=payload.get("id", place_id),
|
||||||
|
name=_parse_display_name(payload.get("displayName")),
|
||||||
|
address=payload.get("formattedAddress"),
|
||||||
|
location=_parse_lat_lng(payload.get("location")),
|
||||||
|
rating=payload.get("rating"),
|
||||||
|
price_level=_parse_price_level(payload.get("priceLevel")),
|
||||||
|
types=payload.get("types"),
|
||||||
|
phone=payload.get("nationalPhoneNumber"),
|
||||||
|
website=payload.get("websiteUri"),
|
||||||
|
hours=_parse_hours(payload.get("regularOpeningHours")),
|
||||||
|
open_now=_parse_open_now(payload.get("currentOpeningHours")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_locations(request: LocationResolveRequest) -> LocationResolveResponse:
|
||||||
|
url = f"{GOOGLE_PLACES_BASE_URL}/places:searchText"
|
||||||
|
body = {"textQuery": request.location_text, "pageSize": request.limit}
|
||||||
|
response = _request("POST", url, body, _RESOLVE_FIELD_MASK)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.error(
|
||||||
|
"Google Places API error %s. response=%s",
|
||||||
|
response.status_code,
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Google Places API error ({response.status_code}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Google Places API returned invalid JSON. response=%s",
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail="Invalid Google response.") from exc
|
||||||
|
|
||||||
|
places = payload.get("places", [])
|
||||||
|
results = []
|
||||||
|
for place in places:
|
||||||
|
results.append(
|
||||||
|
ResolvedLocation(
|
||||||
|
place_id=place.get("id", ""),
|
||||||
|
name=_parse_display_name(place.get("displayName")),
|
||||||
|
address=place.get("formattedAddress"),
|
||||||
|
location=_parse_lat_lng(place.get("location")),
|
||||||
|
types=place.get("types"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return LocationResolveResponse(results=results)
|
||||||
65
skills/local-places/src/local_places/main.py
Normal file
65
skills/local-places/src/local_places/main.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from local_places.google_places import get_place_details, resolve_locations, search_places
|
||||||
|
from local_places.schemas import (
|
||||||
|
LocationResolveRequest,
|
||||||
|
LocationResolveResponse,
|
||||||
|
PlaceDetails,
|
||||||
|
SearchRequest,
|
||||||
|
SearchResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="My API",
|
||||||
|
servers=[{"url": os.getenv("OPENAPI_SERVER_URL", "http://maxims-macbook-air:8000")}],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("local_places.validation")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ping")
|
||||||
|
def ping() -> dict[str, str]:
|
||||||
|
return {"message": "pong"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(
|
||||||
|
request: Request, exc: RequestValidationError
|
||||||
|
) -> JSONResponse:
|
||||||
|
logger.error(
|
||||||
|
"Validation error on %s %s. body=%s errors=%s",
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
exc.body,
|
||||||
|
exc.errors(),
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content=jsonable_encoder({"detail": exc.errors()}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/places/search", response_model=SearchResponse)
|
||||||
|
def places_search(request: SearchRequest) -> SearchResponse:
|
||||||
|
return search_places(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/places/{place_id}", response_model=PlaceDetails)
|
||||||
|
def places_details(place_id: str) -> PlaceDetails:
|
||||||
|
return get_place_details(place_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/locations/resolve", response_model=LocationResolveResponse)
|
||||||
|
def locations_resolve(request: LocationResolveRequest) -> LocationResolveResponse:
|
||||||
|
return resolve_locations(request)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("local_places.main:app", host="0.0.0.0", port=8000)
|
||||||
107
skills/local-places/src/local_places/schemas.py
Normal file
107
skills/local-places/src/local_places/schemas.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class LatLng(BaseModel):
|
||||||
|
lat: float = Field(ge=-90, le=90)
|
||||||
|
lng: float = Field(ge=-180, le=180)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationBias(BaseModel):
|
||||||
|
lat: float = Field(ge=-90, le=90)
|
||||||
|
lng: float = Field(ge=-180, le=180)
|
||||||
|
radius_m: float = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class Filters(BaseModel):
|
||||||
|
types: list[str] | None = None
|
||||||
|
open_now: bool | None = None
|
||||||
|
min_rating: float | None = Field(default=None, ge=0, le=5)
|
||||||
|
price_levels: list[int] | None = None
|
||||||
|
keyword: str | None = Field(default=None, min_length=1)
|
||||||
|
|
||||||
|
@field_validator("types")
|
||||||
|
@classmethod
|
||||||
|
def validate_types(cls, value: list[str] | None) -> list[str] | None:
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
if len(value) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
"Only one type is supported. Use query/keyword for additional filtering."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("price_levels")
|
||||||
|
@classmethod
|
||||||
|
def validate_price_levels(cls, value: list[int] | None) -> list[int] | None:
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
invalid = [level for level in value if level not in range(0, 5)]
|
||||||
|
if invalid:
|
||||||
|
raise ValueError("price_levels must be integers between 0 and 4.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("min_rating")
|
||||||
|
@classmethod
|
||||||
|
def validate_min_rating(cls, value: float | None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
if (value * 2) % 1 != 0:
|
||||||
|
raise ValueError("min_rating must be in 0.5 increments.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
query: str = Field(min_length=1)
|
||||||
|
location_bias: LocationBias | None = None
|
||||||
|
filters: Filters | None = None
|
||||||
|
limit: int = Field(default=10, ge=1, le=20)
|
||||||
|
page_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceSummary(BaseModel):
|
||||||
|
place_id: str
|
||||||
|
name: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
location: LatLng | None = None
|
||||||
|
rating: float | None = None
|
||||||
|
price_level: int | None = None
|
||||||
|
types: list[str] | None = None
|
||||||
|
open_now: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
results: list[PlaceSummary]
|
||||||
|
next_page_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationResolveRequest(BaseModel):
|
||||||
|
location_text: str = Field(min_length=1)
|
||||||
|
limit: int = Field(default=5, ge=1, le=10)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolvedLocation(BaseModel):
|
||||||
|
place_id: str
|
||||||
|
name: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
location: LatLng | None = None
|
||||||
|
types: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationResolveResponse(BaseModel):
|
||||||
|
results: list[ResolvedLocation]
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceDetails(BaseModel):
|
||||||
|
place_id: str
|
||||||
|
name: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
location: LatLng | None = None
|
||||||
|
rating: float | None = None
|
||||||
|
price_level: int | None = None
|
||||||
|
types: list[str] | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
website: str | None = None
|
||||||
|
hours: list[str] | None = None
|
||||||
|
open_now: bool | None = None
|
||||||
29
skills/songsee/SKILL.md
Normal file
29
skills/songsee/SKILL.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: songsee
|
||||||
|
description: Generate spectrograms and feature-panel visualizations from audio with the songsee CLI.
|
||||||
|
homepage: https://github.com/steipete/songsee
|
||||||
|
metadata: {"clawdis":{"emoji":"🌊","requires":{"bins":["songsee"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/songsee","bins":["songsee"],"label":"Install songsee (brew)"}]}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# songsee
|
||||||
|
|
||||||
|
Generate spectrograms + feature panels from audio.
|
||||||
|
|
||||||
|
Quick start
|
||||||
|
- Spectrogram: `songsee track.mp3`
|
||||||
|
- Multi-panel: `songsee track.mp3 --viz spectrogram,mel,chroma,hpss,selfsim,loudness,tempogram,mfcc,flux`
|
||||||
|
- Time slice: `songsee track.mp3 --start 12.5 --duration 8 -o slice.jpg`
|
||||||
|
- Stdin: `cat track.mp3 | songsee - --format png -o out.png`
|
||||||
|
|
||||||
|
Common flags
|
||||||
|
- `--viz` list (repeatable or comma-separated)
|
||||||
|
- `--style` palette (classic, magma, inferno, viridis, gray)
|
||||||
|
- `--width` / `--height` output size
|
||||||
|
- `--window` / `--hop` FFT settings
|
||||||
|
- `--min-freq` / `--max-freq` frequency range
|
||||||
|
- `--start` / `--duration` time slice
|
||||||
|
- `--format` jpg|png
|
||||||
|
|
||||||
|
Notes
|
||||||
|
- WAV/MP3 decode native; other formats use ffmpeg if available.
|
||||||
|
- Multiple `--viz` renders a grid.
|
||||||
84
skills/trello/SKILL.md
Normal file
84
skills/trello/SKILL.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
name: trello
|
||||||
|
description: Manage Trello boards, lists, and cards via the Trello REST API.
|
||||||
|
homepage: https://developer.atlassian.com/cloud/trello/rest/
|
||||||
|
metadata: {"clawdis":{"emoji":"📋","requires":{"bins":["jq"],"env":["TRELLO_API_KEY","TRELLO_TOKEN"]}}}
|
||||||
|
---
|
||||||
|
|
||||||
|
# Trello Skill
|
||||||
|
|
||||||
|
Manage Trello boards, lists, and cards directly from Clawdis.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Get your API key: https://trello.com/app-key
|
||||||
|
2. Generate a token (click "Token" link on that page)
|
||||||
|
3. Set environment variables:
|
||||||
|
```bash
|
||||||
|
export TRELLO_API_KEY="your-api-key"
|
||||||
|
export TRELLO_TOKEN="your-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
All commands use curl to hit the Trello REST API.
|
||||||
|
|
||||||
|
### List boards
|
||||||
|
```bash
|
||||||
|
curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### List lists in a board
|
||||||
|
```bash
|
||||||
|
curl -s "https://api.trello.com/1/boards/{boardId}/lists?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### List cards in a list
|
||||||
|
```bash
|
||||||
|
curl -s "https://api.trello.com/1/lists/{listId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id, desc}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a card
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "https://api.trello.com/1/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
|
||||||
|
-d "idList={listId}" \
|
||||||
|
-d "name=Card Title" \
|
||||||
|
-d "desc=Card description"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Move a card to another list
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
|
||||||
|
-d "idList={newListId}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add a comment to a card
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "https://api.trello.com/1/cards/{cardId}/actions/comments?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
|
||||||
|
-d "text=Your comment here"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Archive a card
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \
|
||||||
|
-d "closed=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Board/List/Card IDs can be found in the Trello URL or via the list commands
|
||||||
|
- The API key and token provide full access to your Trello account - keep them secret!
|
||||||
|
- Rate limits: 300 requests per 10 seconds per API key; 100 requests per 10 seconds per token; `/1/members` endpoints are limited to 100 requests per 900 seconds
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get all boards
|
||||||
|
curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN&fields=name,id" | jq
|
||||||
|
|
||||||
|
# Find a specific board by name
|
||||||
|
curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | select(.name | contains("Work"))'
|
||||||
|
|
||||||
|
# Get all cards on a board
|
||||||
|
curl -s "https://api.trello.com/1/boards/{boardId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, list: .idList}'
|
||||||
|
```
|
||||||
@ -10,7 +10,7 @@ describe("buildAgentSystemPromptAppend", () => {
|
|||||||
|
|
||||||
expect(prompt).toContain("## User Identity");
|
expect(prompt).toContain("## User Identity");
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
"Owner numbers: +123, +456. Treat messages from these numbers as the user (Peter).",
|
"Owner numbers: +123, +456. Treat messages from these numbers as the user.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export function buildAgentSystemPromptAppend(params: {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const ownerLine =
|
const ownerLine =
|
||||||
ownerNumbers.length > 0
|
ownerNumbers.length > 0
|
||||||
? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user (Peter).`
|
? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.`
|
||||||
: undefined;
|
: undefined;
|
||||||
const reasoningHint = params.reasoningTagHint
|
const reasoningHint = params.reasoningTagHint
|
||||||
? [
|
? [
|
||||||
@ -36,7 +36,7 @@ export function buildAgentSystemPromptAppend(params: {
|
|||||||
"Only text inside <final> is shown to the user; everything else is discarded and never seen by the user.",
|
"Only text inside <final> is shown to the user; everything else is discarded and never seen by the user.",
|
||||||
"Example:",
|
"Example:",
|
||||||
"<think>Short internal reasoning.</think>",
|
"<think>Short internal reasoning.</think>",
|
||||||
"<final>Hey Peter! What would you like to do next?</final>",
|
"<final>Hey there! What would you like to do next?</final>",
|
||||||
].join(" ")
|
].join(" ")
|
||||||
: undefined;
|
: undefined;
|
||||||
const runtimeInfo = params.runtimeInfo;
|
const runtimeInfo = params.runtimeInfo;
|
||||||
|
|||||||
@ -118,7 +118,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: path.join(home, "sessions.json") },
|
session: { store: path.join(home, "sessions.json") },
|
||||||
@ -168,7 +168,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: { allowFrom: ["*"] },
|
whatsapp: { allowFrom: ["*"] },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -195,7 +195,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: { allowFrom: ["*"] },
|
whatsapp: { allowFrom: ["*"] },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -208,7 +208,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: { allowFrom: ["*"] },
|
whatsapp: { allowFrom: ["*"] },
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -264,7 +264,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
@ -325,7 +325,7 @@ describe("directive parsing", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
@ -506,7 +506,7 @@ describe("directive parsing", () => {
|
|||||||
workspace: path.join(home, "clawd"),
|
workspace: path.join(home, "clawd"),
|
||||||
allowedModels: ["openai/gpt-4.1-mini"],
|
allowedModels: ["openai/gpt-4.1-mini"],
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: storePath },
|
session: { store: storePath },
|
||||||
|
|||||||
@ -42,7 +42,7 @@ function makeCfg(home: string) {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: join(home, "clawd"),
|
workspace: join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: { store: join(home, "sessions.json") },
|
session: { store: join(home, "sessions.json") },
|
||||||
@ -220,6 +220,7 @@ describe("trigger handling", () => {
|
|||||||
From: "123@g.us",
|
From: "123@g.us",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
|
Surface: "whatsapp",
|
||||||
SenderE164: "+2000",
|
SenderE164: "+2000",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -230,7 +231,7 @@ describe("trigger handling", () => {
|
|||||||
const store = JSON.parse(
|
const store = JSON.parse(
|
||||||
await fs.readFile(cfg.session.store, "utf-8"),
|
await fs.readFile(cfg.session.store, "utf-8"),
|
||||||
) as Record<string, { groupActivation?: string }>;
|
) as Record<string, { groupActivation?: string }>;
|
||||||
expect(store["group:123@g.us"]?.groupActivation).toBe("always");
|
expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -244,6 +245,7 @@ describe("trigger handling", () => {
|
|||||||
From: "123@g.us",
|
From: "123@g.us",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
|
Surface: "whatsapp",
|
||||||
SenderE164: "+999",
|
SenderE164: "+999",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@ -270,6 +272,7 @@ describe("trigger handling", () => {
|
|||||||
From: "123@g.us",
|
From: "123@g.us",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
|
Surface: "whatsapp",
|
||||||
SenderE164: "+2000",
|
SenderE164: "+2000",
|
||||||
GroupSubject: "Test Group",
|
GroupSubject: "Test Group",
|
||||||
GroupMembers: "Alice (+1), Bob (+2)",
|
GroupMembers: "Alice (+1), Bob (+2)",
|
||||||
@ -280,8 +283,10 @@ describe("trigger handling", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: join(home, "clawd"),
|
workspace: join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
|
},
|
||||||
|
routing: {
|
||||||
groupChat: { requireMention: false },
|
groupChat: { requireMention: false },
|
||||||
},
|
},
|
||||||
session: { store: join(home, "sessions.json") },
|
session: { store: join(home, "sessions.json") },
|
||||||
@ -321,7 +326,7 @@ describe("trigger handling", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: join(home, "clawd"),
|
workspace: join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
@ -360,7 +365,7 @@ describe("trigger handling", () => {
|
|||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: join(home, "clawd"),
|
workspace: join(home, "clawd"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: ["*"],
|
allowFrom: ["*"],
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
@ -27,9 +27,11 @@ import {
|
|||||||
} from "../agents/workspace.js";
|
} from "../agents/workspace.js";
|
||||||
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
buildGroupDisplayName,
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
DEFAULT_RESET_TRIGGERS,
|
DEFAULT_RESET_TRIGGERS,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveGroupSessionKey,
|
||||||
resolveSessionKey,
|
resolveSessionKey,
|
||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
@ -364,9 +366,9 @@ export async function getReplyFromConfig(
|
|||||||
let persistedModelOverride: string | undefined;
|
let persistedModelOverride: string | undefined;
|
||||||
let persistedProviderOverride: string | undefined;
|
let persistedProviderOverride: string | undefined;
|
||||||
|
|
||||||
|
const groupResolution = resolveGroupSessionKey(ctx);
|
||||||
const isGroup =
|
const isGroup =
|
||||||
typeof ctx.From === "string" &&
|
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
||||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
|
||||||
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
|
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
@ -399,6 +401,13 @@ export async function getReplyFromConfig(
|
|||||||
|
|
||||||
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
|
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
|
||||||
sessionStore = loadSessionStore(storePath);
|
sessionStore = loadSessionStore(storePath);
|
||||||
|
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
|
||||||
|
const legacyEntry = sessionStore[groupResolution.legacyKey];
|
||||||
|
if (legacyEntry && !sessionStore[sessionKey]) {
|
||||||
|
sessionStore[sessionKey] = legacyEntry;
|
||||||
|
delete sessionStore[groupResolution.legacyKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
const entry = sessionStore[sessionKey];
|
const entry = sessionStore[sessionKey];
|
||||||
const idleMs = idleMinutes * 60_000;
|
const idleMs = idleMinutes * 60_000;
|
||||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||||
@ -431,7 +440,41 @@ export async function getReplyFromConfig(
|
|||||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||||
queueMode: baseEntry?.queueMode,
|
queueMode: baseEntry?.queueMode,
|
||||||
|
displayName: baseEntry?.displayName,
|
||||||
|
chatType: baseEntry?.chatType,
|
||||||
|
surface: baseEntry?.surface,
|
||||||
|
subject: baseEntry?.subject,
|
||||||
|
room: baseEntry?.room,
|
||||||
|
space: baseEntry?.space,
|
||||||
};
|
};
|
||||||
|
if (groupResolution?.surface) {
|
||||||
|
const surface = groupResolution.surface;
|
||||||
|
const subject = ctx.GroupSubject?.trim();
|
||||||
|
const space = ctx.GroupSpace?.trim();
|
||||||
|
const explicitRoom = ctx.GroupRoom?.trim();
|
||||||
|
const isRoomSurface = surface === "discord" || surface === "slack";
|
||||||
|
const nextRoom =
|
||||||
|
explicitRoom ??
|
||||||
|
(isRoomSurface && subject && subject.startsWith("#")
|
||||||
|
? subject
|
||||||
|
: undefined);
|
||||||
|
const nextSubject = nextRoom ? undefined : subject;
|
||||||
|
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
||||||
|
sessionEntry.surface = surface;
|
||||||
|
if (nextSubject) sessionEntry.subject = nextSubject;
|
||||||
|
if (nextRoom) sessionEntry.room = nextRoom;
|
||||||
|
if (space) sessionEntry.space = space;
|
||||||
|
sessionEntry.displayName = buildGroupDisplayName({
|
||||||
|
surface: sessionEntry.surface,
|
||||||
|
subject: sessionEntry.subject,
|
||||||
|
room: sessionEntry.room,
|
||||||
|
space: sessionEntry.space,
|
||||||
|
id: groupResolution.id,
|
||||||
|
key: sessionKey,
|
||||||
|
});
|
||||||
|
} else if (!sessionEntry.chatType) {
|
||||||
|
sessionEntry.chatType = "direct";
|
||||||
|
}
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, sessionStore);
|
await saveSessionStore(storePath, sessionStore);
|
||||||
|
|
||||||
@ -798,14 +841,20 @@ export async function getReplyFromConfig(
|
|||||||
const perMessageQueueMode =
|
const perMessageQueueMode =
|
||||||
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
|
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
|
||||||
|
|
||||||
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
|
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||||
const configuredAllowFrom = cfg.routing?.allowFrom;
|
const isWhatsAppSurface =
|
||||||
|
surface === "whatsapp" ||
|
||||||
|
(ctx.From ?? "").startsWith("whatsapp:") ||
|
||||||
|
(ctx.To ?? "").startsWith("whatsapp:");
|
||||||
|
|
||||||
|
// WhatsApp owner allowlist (E.164 without whatsapp: prefix); used for group activation only.
|
||||||
|
const configuredAllowFrom = isWhatsAppSurface
|
||||||
|
? cfg.whatsapp?.allowFrom
|
||||||
|
: undefined;
|
||||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||||
const isSamePhone = from && to && from === to;
|
|
||||||
// If no config is present, default to self-only DM access.
|
|
||||||
const defaultAllowFrom =
|
const defaultAllowFrom =
|
||||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) && to
|
isWhatsAppSurface && (!configuredAllowFrom || configuredAllowFrom.length === 0) && to
|
||||||
? [to]
|
? [to]
|
||||||
: undefined;
|
: undefined;
|
||||||
const allowFrom =
|
const allowFrom =
|
||||||
@ -819,10 +868,12 @@ export async function getReplyFromConfig(
|
|||||||
: rawBodyNormalized;
|
: rawBodyNormalized;
|
||||||
const activationCommand = parseActivationCommand(commandBodyNormalized);
|
const activationCommand = parseActivationCommand(commandBodyNormalized);
|
||||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||||
const ownerCandidates = (allowFrom ?? []).filter(
|
const ownerCandidates = isWhatsAppSurface
|
||||||
(entry) => entry && entry !== "*",
|
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
|
||||||
);
|
: [];
|
||||||
if (ownerCandidates.length === 0 && to) ownerCandidates.push(to);
|
if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
|
||||||
|
ownerCandidates.push(to);
|
||||||
|
}
|
||||||
const ownerList = ownerCandidates
|
const ownerList = ownerCandidates
|
||||||
.map((entry) => normalizeE164(entry))
|
.map((entry) => normalizeE164(entry))
|
||||||
.filter((entry): entry is string => Boolean(entry));
|
.filter((entry): entry is string => Boolean(entry));
|
||||||
@ -833,20 +884,6 @@ export async function getReplyFromConfig(
|
|||||||
abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false;
|
abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same-phone mode (self-messaging) is always allowed
|
|
||||||
if (isSamePhone) {
|
|
||||||
logVerbose(`Allowing same-phone mode: from === to (${from})`);
|
|
||||||
} else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
|
||||||
// Support "*" as wildcard to allow all senders
|
|
||||||
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
|
|
||||||
logVerbose(
|
|
||||||
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
|
|
||||||
);
|
|
||||||
cleanupTyping();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activationCommand.hasCommand) {
|
if (activationCommand.hasCommand) {
|
||||||
if (!isGroup) {
|
if (!isGroup) {
|
||||||
cleanupTyping();
|
cleanupTyping();
|
||||||
@ -1038,8 +1075,7 @@ export async function getReplyFromConfig(
|
|||||||
// Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot.
|
// Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot.
|
||||||
// Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact.
|
// Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact.
|
||||||
const isGroupSession =
|
const isGroupSession =
|
||||||
typeof ctx.From === "string" &&
|
sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
|
||||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
|
||||||
const isMainSession =
|
const isMainSession =
|
||||||
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
||||||
if (isMainSession) {
|
if (isMainSession) {
|
||||||
|
|||||||
@ -63,8 +63,9 @@ describe("buildStatusMessage", () => {
|
|||||||
sessionId: "g1",
|
sessionId: "g1",
|
||||||
updatedAt: 0,
|
updatedAt: 0,
|
||||||
groupActivation: "always",
|
groupActivation: "always",
|
||||||
|
chatType: "group",
|
||||||
},
|
},
|
||||||
sessionKey: "group:123@g.us",
|
sessionKey: "whatsapp:group:123@g.us",
|
||||||
sessionScope: "per-sender",
|
sessionScope: "per-sender",
|
||||||
webLinked: true,
|
webLinked: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -191,7 +191,13 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" • ");
|
.join(" • ");
|
||||||
|
|
||||||
const groupActivationLine = args.sessionKey?.startsWith("group:")
|
const isGroupSession =
|
||||||
|
entry?.chatType === "group" ||
|
||||||
|
entry?.chatType === "room" ||
|
||||||
|
Boolean(args.sessionKey?.includes(":group:")) ||
|
||||||
|
Boolean(args.sessionKey?.includes(":channel:")) ||
|
||||||
|
Boolean(args.sessionKey?.startsWith("group:"));
|
||||||
|
const groupActivationLine = isGroupSession
|
||||||
? `Group activation: ${entry?.groupActivation ?? "mention"}`
|
? `Group activation: ${entry?.groupActivation ?? "mention"}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export type MsgContext = {
|
|||||||
Body?: string;
|
Body?: string;
|
||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
|
SessionKey?: string;
|
||||||
MessageSid?: string;
|
MessageSid?: string;
|
||||||
ReplyToId?: string;
|
ReplyToId?: string;
|
||||||
ReplyToBody?: string;
|
ReplyToBody?: string;
|
||||||
@ -12,6 +13,8 @@ export type MsgContext = {
|
|||||||
Transcript?: string;
|
Transcript?: string;
|
||||||
ChatType?: string;
|
ChatType?: string;
|
||||||
GroupSubject?: string;
|
GroupSubject?: string;
|
||||||
|
GroupRoom?: string;
|
||||||
|
GroupSpace?: string;
|
||||||
GroupMembers?: string;
|
GroupMembers?: string;
|
||||||
SenderName?: string;
|
SenderName?: string;
|
||||||
SenderE164?: string;
|
SenderE164?: string;
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36
|
969df6da368b3a802bf0f7f34bf2e30102ae51d91daf45f1fb9328877e2fb335
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { danger, setVerbose } from "../globals.js";
|
|||||||
import { loginWeb, logoutWeb } from "../provider-web.js";
|
import { loginWeb, logoutWeb } from "../provider-web.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
import { readConfigFileSnapshot } from "../config/config.js";
|
||||||
import { registerBrowserCli } from "./browser-cli.js";
|
import { registerBrowserCli } from "./browser-cli.js";
|
||||||
import { registerCanvasCli } from "./canvas-cli.js";
|
import { registerCanvasCli } from "./canvas-cli.js";
|
||||||
import { registerCronCli } from "./cron-cli.js";
|
import { registerCronCli } from "./cron-cli.js";
|
||||||
@ -68,6 +69,21 @@ export function buildProgram() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
|
||||||
|
|
||||||
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||||
|
if (actionCommand.name() === "doctor") return;
|
||||||
|
const snapshot = await readConfigFileSnapshot();
|
||||||
|
if (snapshot.legacyIssues.length === 0) return;
|
||||||
|
const issues = snapshot.legacyIssues
|
||||||
|
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||||
|
.join("\n");
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
`Legacy config entries detected. Run \"clawdis doctor\" (or ask your agent) to migrate.\n${issues}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
const examples = [
|
const examples = [
|
||||||
[
|
[
|
||||||
"clawdis login --verbose",
|
"clawdis login --verbose",
|
||||||
|
|||||||
@ -158,7 +158,7 @@ export async function agentCommand(
|
|||||||
});
|
});
|
||||||
const workspaceDir = workspace.dir;
|
const workspaceDir = workspace.dir;
|
||||||
|
|
||||||
const allowFrom = (cfg.routing?.allowFrom ?? [])
|
const allowFrom = (cfg.whatsapp?.allowFrom ?? [])
|
||||||
.map((val) => normalizeE164(val))
|
.map((val) => normalizeE164(val))
|
||||||
.filter((val) => val.length > 1);
|
.filter((val) => val.length > 1);
|
||||||
|
|
||||||
@ -451,7 +451,7 @@ export async function agentCommand(
|
|||||||
if (deliver) {
|
if (deliver) {
|
||||||
if (deliveryProvider === "whatsapp" && !whatsappTarget) {
|
if (deliveryProvider === "whatsapp" && !whatsappTarget) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
"Delivering to WhatsApp requires --to <E.164> or routing.allowFrom[0]",
|
"Delivering to WhatsApp requires --to <E.164> or whatsapp.allowFrom[0]",
|
||||||
);
|
);
|
||||||
if (!bestEffortDeliver) throw err;
|
if (!bestEffortDeliver) throw err;
|
||||||
logDeliveryError(err);
|
logDeliveryError(err);
|
||||||
@ -470,7 +470,7 @@ export async function agentCommand(
|
|||||||
}
|
}
|
||||||
if (deliveryProvider === "signal" && !signalTarget) {
|
if (deliveryProvider === "signal" && !signalTarget) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
"Delivering to Signal requires --to <E.164|group:ID|signal:+E.164>",
|
"Delivering to Signal requires --to <E.164|group:ID|signal:group:ID|signal:+E.164>",
|
||||||
);
|
);
|
||||||
if (!bestEffortDeliver) throw err;
|
if (!bestEffortDeliver) throw err;
|
||||||
logDeliveryError(err);
|
logDeliveryError(err);
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import {
|
|||||||
printWizardHeader,
|
printWizardHeader,
|
||||||
probeGatewayReachable,
|
probeGatewayReachable,
|
||||||
randomToken,
|
randomToken,
|
||||||
|
resolveControlUiLinks,
|
||||||
summarizeExistingConfig,
|
summarizeExistingConfig,
|
||||||
} from "./onboard-helpers.js";
|
} from "./onboard-helpers.js";
|
||||||
import { setupProviders } from "./onboard-providers.js";
|
import { setupProviders } from "./onboard-providers.js";
|
||||||
@ -550,6 +551,30 @@ export async function runConfigureWizard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
note(
|
||||||
|
(() => {
|
||||||
|
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||||
|
const links = resolveControlUiLinks({ bind, port: gatewayPort });
|
||||||
|
return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
})(),
|
||||||
|
"Control UI",
|
||||||
|
);
|
||||||
|
|
||||||
|
const wantsOpen = guardCancel(
|
||||||
|
await confirm({
|
||||||
|
message: "Open Control UI now?",
|
||||||
|
initialValue: false,
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
if (wantsOpen) {
|
||||||
|
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||||
|
const links = resolveControlUiLinks({ bind, port: gatewayPort });
|
||||||
|
await openUrl(links.httpUrl);
|
||||||
|
}
|
||||||
|
|
||||||
outro("Configure complete.");
|
outro("Configure complete.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
src/commands/doctor.test.ts
Normal file
101
src/commands/doctor.test.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readConfigFileSnapshot = vi.fn();
|
||||||
|
const writeConfigFile = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const migrateLegacyConfig = vi.fn((raw: unknown) => ({
|
||||||
|
config: raw as Record<string, unknown>,
|
||||||
|
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@clack/prompts", () => ({
|
||||||
|
confirm: vi.fn().mockResolvedValue(true),
|
||||||
|
intro: vi.fn(),
|
||||||
|
note: vi.fn(),
|
||||||
|
outro: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../agents/skills-status.js", () => ({
|
||||||
|
buildWorkspaceSkillStatus: () => ({ skills: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json",
|
||||||
|
readConfigFileSnapshot,
|
||||||
|
writeConfigFile,
|
||||||
|
migrateLegacyConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../runtime.js", () => ({
|
||||||
|
defaultRuntime: {
|
||||||
|
log: () => {},
|
||||||
|
error: () => {},
|
||||||
|
exit: () => {
|
||||||
|
throw new Error("exit");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../utils.js", () => ({
|
||||||
|
resolveUserPath: (value: string) => value,
|
||||||
|
sleep: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./health.js", () => ({
|
||||||
|
healthCommand: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./onboard-helpers.js", () => ({
|
||||||
|
applyWizardMetadata: (cfg: Record<string, unknown>) => cfg,
|
||||||
|
DEFAULT_WORKSPACE: "/tmp",
|
||||||
|
guardCancel: (value: unknown) => value,
|
||||||
|
printWizardHeader: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("doctor", () => {
|
||||||
|
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||||
|
readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
path: "/tmp/clawdis.json",
|
||||||
|
exists: true,
|
||||||
|
raw: "{}",
|
||||||
|
parsed: { routing: { allowFrom: ["+15555550123"] } },
|
||||||
|
valid: false,
|
||||||
|
config: {},
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
path: "routing.allowFrom",
|
||||||
|
message: "legacy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legacyIssues: [
|
||||||
|
{
|
||||||
|
path: "routing.allowFrom",
|
||||||
|
message: "legacy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { doctorCommand } = await import("./doctor.js");
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
migrateLegacyConfig.mockReturnValue({
|
||||||
|
config: { whatsapp: { allowFrom: ["+15555550123"] } },
|
||||||
|
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
|
||||||
|
});
|
||||||
|
|
||||||
|
await doctorCommand(runtime);
|
||||||
|
|
||||||
|
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||||
|
const written = writeConfigFile.mock.calls[0]?.[0] as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect((written.whatsapp as Record<string, unknown>)?.allowFrom).toEqual([
|
||||||
|
"+15555550123",
|
||||||
|
]);
|
||||||
|
expect(written.routing).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,6 +4,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
|||||||
import type { ClawdisConfig } from "../config/config.js";
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
CONFIG_PATH_CLAWDIS,
|
CONFIG_PATH_CLAWDIS,
|
||||||
|
migrateLegacyConfig,
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
@ -29,10 +30,36 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
|
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {};
|
let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {};
|
||||||
if (snapshot.exists && !snapshot.valid) {
|
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
|
||||||
note("Config invalid; doctor will run with defaults.", "Config");
|
note("Config invalid; doctor will run with defaults.", "Config");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (snapshot.legacyIssues.length > 0) {
|
||||||
|
note(
|
||||||
|
snapshot.legacyIssues
|
||||||
|
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||||
|
.join("\n"),
|
||||||
|
"Legacy config keys detected",
|
||||||
|
);
|
||||||
|
const migrate = guardCancel(
|
||||||
|
await confirm({
|
||||||
|
message: "Migrate legacy config entries now?",
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
if (migrate) {
|
||||||
|
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
|
||||||
|
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
note(changes.join("\n"), "Doctor changes");
|
||||||
|
}
|
||||||
|
if (migrated) {
|
||||||
|
cfg = migrated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const workspaceDir = resolveUserPath(
|
const workspaceDir = resolveUserPath(
|
||||||
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
|
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
|
||||||
);
|
);
|
||||||
@ -57,7 +84,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||||
healthOk = true;
|
healthOk = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(`Health check failed: ${String(err)}`);
|
const message = String(err);
|
||||||
|
if (message.includes("gateway closed")) {
|
||||||
|
note("Gateway not running.", "Gateway");
|
||||||
|
} else {
|
||||||
|
runtime.error(`Health check failed: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!healthOk) {
|
if (!healthOk) {
|
||||||
@ -79,7 +111,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
try {
|
try {
|
||||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(`Health check failed: ${String(err)}`);
|
const message = String(err);
|
||||||
|
if (message.includes("gateway closed")) {
|
||||||
|
note("Gateway not running.", "Gateway");
|
||||||
|
} else {
|
||||||
|
runtime.error(`Health check failed: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { inspect } from "node:util";
|
||||||
|
|
||||||
import { cancel, isCancel } from "@clack/prompts";
|
import { cancel, isCancel } from "@clack/prompts";
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ import type { ClawdisConfig } from "../config/config.js";
|
|||||||
import { CONFIG_PATH_CLAWDIS } from "../config/config.js";
|
import { CONFIG_PATH_CLAWDIS } from "../config/config.js";
|
||||||
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||||
@ -195,7 +197,14 @@ export async function probeGatewayReachable(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function summarizeError(err: unknown): string {
|
function summarizeError(err: unknown): string {
|
||||||
const raw = String(err ?? "unknown error");
|
let raw = "unknown error";
|
||||||
|
if (err instanceof Error) {
|
||||||
|
raw = err.message || raw;
|
||||||
|
} else if (typeof err === "string") {
|
||||||
|
raw = err || raw;
|
||||||
|
} else if (err !== undefined) {
|
||||||
|
raw = inspect(err, { depth: 2 });
|
||||||
|
}
|
||||||
const line =
|
const line =
|
||||||
raw
|
raw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@ -205,3 +214,20 @@ function summarizeError(err: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
|
export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
|
||||||
|
|
||||||
|
export function resolveControlUiLinks(params: {
|
||||||
|
port: number;
|
||||||
|
bind?: "auto" | "lan" | "tailnet" | "loopback";
|
||||||
|
}): { httpUrl: string; wsUrl: string } {
|
||||||
|
const port = params.port;
|
||||||
|
const bind = params.bind ?? "loopback";
|
||||||
|
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||||
|
const host =
|
||||||
|
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
|
||||||
|
? (tailnetIPv4 ?? "127.0.0.1")
|
||||||
|
: "127.0.0.1";
|
||||||
|
return {
|
||||||
|
httpUrl: `http://${host}:${port}/`,
|
||||||
|
wsUrl: `ws://${host}:${port}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import {
|
|||||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
@ -40,6 +39,7 @@ import {
|
|||||||
printWizardHeader,
|
printWizardHeader,
|
||||||
probeGatewayReachable,
|
probeGatewayReachable,
|
||||||
randomToken,
|
randomToken,
|
||||||
|
resolveControlUiLinks,
|
||||||
summarizeExistingConfig,
|
summarizeExistingConfig,
|
||||||
} from "./onboard-helpers.js";
|
} from "./onboard-helpers.js";
|
||||||
import { setupProviders } from "./onboard-providers.js";
|
import { setupProviders } from "./onboard-providers.js";
|
||||||
@ -280,8 +280,16 @@ export async function runInteractiveOnboarding(
|
|||||||
await select({
|
await select({
|
||||||
message: "Gateway auth",
|
message: "Gateway auth",
|
||||||
options: [
|
options: [
|
||||||
{ value: "off", label: "Off (loopback only)" },
|
{
|
||||||
{ value: "token", label: "Token" },
|
value: "off",
|
||||||
|
label: "Off (loopback only)",
|
||||||
|
hint: "Recommended for single-machine setups",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "token",
|
||||||
|
label: "Token",
|
||||||
|
hint: "Use for multi-machine access or non-loopback binds",
|
||||||
|
},
|
||||||
{ value: "password", label: "Password" },
|
{ value: "password", label: "Password" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@ -344,6 +352,7 @@ export async function runInteractiveOnboarding(
|
|||||||
const tokenInput = guardCancel(
|
const tokenInput = guardCancel(
|
||||||
await text({
|
await text({
|
||||||
message: "Gateway token (blank to generate)",
|
message: "Gateway token (blank to generate)",
|
||||||
|
placeholder: "Needed for multi-machine or non-loopback access",
|
||||||
initialValue: randomToken(),
|
initialValue: randomToken(),
|
||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
@ -375,7 +384,11 @@ export async function runInteractiveOnboarding(
|
|||||||
...nextConfig,
|
...nextConfig,
|
||||||
gateway: {
|
gateway: {
|
||||||
...nextConfig.gateway,
|
...nextConfig.gateway,
|
||||||
auth: { ...nextConfig.gateway?.auth, mode: "token" },
|
auth: {
|
||||||
|
...nextConfig.gateway?.auth,
|
||||||
|
mode: "token",
|
||||||
|
token: gatewayToken,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -481,18 +494,38 @@ export async function runInteractiveOnboarding(
|
|||||||
|
|
||||||
note(
|
note(
|
||||||
(() => {
|
(() => {
|
||||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
const links = resolveControlUiLinks({ bind, port });
|
||||||
const host =
|
const tokenParam =
|
||||||
bind === "tailnet" || (bind === "auto" && tailnetIPv4)
|
authMode === "token" && gatewayToken
|
||||||
? (tailnetIPv4 ?? "127.0.0.1")
|
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||||
: "127.0.0.1";
|
: "";
|
||||||
|
const authedUrl = `${links.httpUrl}${tokenParam}`;
|
||||||
return [
|
return [
|
||||||
`Control UI: http://${host}:${port}/`,
|
`Web UI: ${links.httpUrl}`,
|
||||||
`Gateway WS: ws://${host}:${port}`,
|
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
|
||||||
].join("\n");
|
`Gateway WS: ${links.wsUrl}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
})(),
|
})(),
|
||||||
"Open the Control UI",
|
"Control UI",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const wantsOpen = guardCancel(
|
||||||
|
await confirm({
|
||||||
|
message: "Open Control UI now?",
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
if (wantsOpen) {
|
||||||
|
const links = resolveControlUiLinks({ bind, port });
|
||||||
|
const tokenParam =
|
||||||
|
authMode === "token" && gatewayToken
|
||||||
|
? `?token=${encodeURIComponent(gatewayToken)}`
|
||||||
|
: "";
|
||||||
|
await openUrl(`${links.httpUrl}${tokenParam}`);
|
||||||
|
}
|
||||||
|
|
||||||
outro("Onboarding complete.");
|
outro("Onboarding complete.");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,93 @@ function noteDiscordTokenHelp(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
whatsapp: {
|
||||||
|
...cfg.whatsapp,
|
||||||
|
allowFrom,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptWhatsAppAllowFrom(
|
||||||
|
cfg: ClawdisConfig,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
): Promise<ClawdisConfig> {
|
||||||
|
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
|
||||||
|
const existingLabel =
|
||||||
|
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
|
||||||
|
|
||||||
|
note(
|
||||||
|
[
|
||||||
|
"WhatsApp direct chats are gated by `whatsapp.allowFrom`.",
|
||||||
|
'Default (unset) = self-chat only; use "*" to allow anyone.',
|
||||||
|
`Current: ${existingLabel}`,
|
||||||
|
].join("\n"),
|
||||||
|
"WhatsApp allowlist",
|
||||||
|
);
|
||||||
|
|
||||||
|
const options =
|
||||||
|
existingAllowFrom.length > 0
|
||||||
|
? ([
|
||||||
|
{ value: "keep", label: "Keep current" },
|
||||||
|
{ value: "self", label: "Self-chat only (unset)" },
|
||||||
|
{ value: "list", label: "Specific numbers (recommended)" },
|
||||||
|
{ value: "any", label: "Anyone (*)" },
|
||||||
|
] as const)
|
||||||
|
: ([
|
||||||
|
{ value: "self", label: "Self-chat only (default)" },
|
||||||
|
{ value: "list", label: "Specific numbers (recommended)" },
|
||||||
|
{ value: "any", label: "Anyone (*)" },
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const mode = guardCancel(
|
||||||
|
await select({
|
||||||
|
message: "Who can trigger the bot via WhatsApp?",
|
||||||
|
options: options.map((opt) => ({ value: opt.value, label: opt.label })),
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
) as (typeof options)[number]["value"];
|
||||||
|
|
||||||
|
if (mode === "keep") return cfg;
|
||||||
|
if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined);
|
||||||
|
if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]);
|
||||||
|
|
||||||
|
const allowRaw = guardCancel(
|
||||||
|
await text({
|
||||||
|
message: "Allowed sender numbers (comma-separated, E.164)",
|
||||||
|
placeholder: "+15555550123, +447700900123",
|
||||||
|
validate: (value) => {
|
||||||
|
const raw = String(value ?? "").trim();
|
||||||
|
if (!raw) return "Required";
|
||||||
|
const parts = raw
|
||||||
|
.split(/[\n,;]+/g)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length === 0) return "Required";
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === "*") continue;
|
||||||
|
const normalized = normalizeE164(part);
|
||||||
|
if (!normalized) return `Invalid number: ${part}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parts = String(allowRaw)
|
||||||
|
.split(/[\n,;]+/g)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const normalized = parts.map((part) =>
|
||||||
|
part === "*" ? "*" : normalizeE164(part),
|
||||||
|
);
|
||||||
|
const unique = [...new Set(normalized.filter(Boolean))];
|
||||||
|
return setWhatsAppAllowFrom(cfg, unique);
|
||||||
|
}
|
||||||
|
|
||||||
export async function setupProviders(
|
export async function setupProviders(
|
||||||
cfg: ClawdisConfig,
|
cfg: ClawdisConfig,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
@ -198,70 +285,7 @@ export async function setupProviders(
|
|||||||
note("Run `clawdis login` later to link WhatsApp.", "WhatsApp");
|
note("Run `clawdis login` later to link WhatsApp.", "WhatsApp");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingAllowFrom = cfg.routing?.allowFrom ?? [];
|
next = await promptWhatsAppAllowFrom(next, runtime);
|
||||||
if (existingAllowFrom.length === 0) {
|
|
||||||
note(
|
|
||||||
[
|
|
||||||
"WhatsApp direct chats are gated by `routing.allowFrom`.",
|
|
||||||
'Default (unset) = self-chat only; use "*" to allow anyone.',
|
|
||||||
].join("\n"),
|
|
||||||
"Allowlist (recommended)",
|
|
||||||
);
|
|
||||||
const mode = guardCancel(
|
|
||||||
await select({
|
|
||||||
message: "Who can trigger the bot via WhatsApp?",
|
|
||||||
options: [
|
|
||||||
{ value: "self", label: "Self-chat only (default)" },
|
|
||||||
{ value: "list", label: "Specific numbers (recommended)" },
|
|
||||||
{ value: "any", label: "Anyone (*)" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
) as "self" | "list" | "any";
|
|
||||||
|
|
||||||
if (mode === "any") {
|
|
||||||
next = {
|
|
||||||
...next,
|
|
||||||
routing: { ...next.routing, allowFrom: ["*"] },
|
|
||||||
};
|
|
||||||
} else if (mode === "list") {
|
|
||||||
const allowRaw = guardCancel(
|
|
||||||
await text({
|
|
||||||
message: "Allowed sender numbers (comma-separated, E.164)",
|
|
||||||
placeholder: "+15555550123, +447700900123",
|
|
||||||
validate: (value) => {
|
|
||||||
const raw = String(value ?? "").trim();
|
|
||||||
if (!raw) return "Required";
|
|
||||||
const parts = raw
|
|
||||||
.split(/[\n,;]+/g)
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (parts.length === 0) return "Required";
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part === "*") continue;
|
|
||||||
const normalized = normalizeE164(part);
|
|
||||||
if (!normalized) return `Invalid number: ${part}`;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
|
|
||||||
const parts = String(allowRaw)
|
|
||||||
.split(/[\n,;]+/g)
|
|
||||||
.map((p) => p.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const normalized = parts.map((part) =>
|
|
||||||
part === "*" ? "*" : normalizeE164(part),
|
|
||||||
);
|
|
||||||
const unique = [...new Set(normalized.filter(Boolean))];
|
|
||||||
next = {
|
|
||||||
...next,
|
|
||||||
routing: { ...next.routing, allowFrom: unique },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection.includes("telegram")) {
|
if (selection.includes("telegram")) {
|
||||||
@ -277,7 +301,15 @@ export async function setupProviders(
|
|||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
if (!keepEnv) {
|
if (keepEnv) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
telegram: {
|
||||||
|
...next.telegram,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
token = String(
|
token = String(
|
||||||
guardCancel(
|
guardCancel(
|
||||||
await text({
|
await text({
|
||||||
@ -344,7 +376,15 @@ export async function setupProviders(
|
|||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
if (!keepEnv) {
|
if (keepEnv) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
discord: {
|
||||||
|
...next.discord,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
token = String(
|
token = String(
|
||||||
guardCancel(
|
guardCancel(
|
||||||
await text({
|
await text({
|
||||||
|
|||||||
@ -22,6 +22,21 @@ function summarizeInstallFailure(message: string): string | undefined {
|
|||||||
return cleaned.length > maxLen ? `${cleaned.slice(0, maxLen - 1)}…` : cleaned;
|
return cleaned.length > maxLen ? `${cleaned.slice(0, maxLen - 1)}…` : cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSkillHint(skill: {
|
||||||
|
description?: string;
|
||||||
|
install: Array<{ label: string }>;
|
||||||
|
}): string {
|
||||||
|
const desc = skill.description?.trim();
|
||||||
|
const installLabel = skill.install[0]?.label?.trim();
|
||||||
|
const combined =
|
||||||
|
desc && installLabel ? `${desc} — ${installLabel}` : desc || installLabel;
|
||||||
|
if (!combined) return "install";
|
||||||
|
const maxLen = 90;
|
||||||
|
return combined.length > maxLen
|
||||||
|
? `${combined.slice(0, maxLen - 1)}…`
|
||||||
|
: combined;
|
||||||
|
}
|
||||||
|
|
||||||
function upsertSkillEntry(
|
function upsertSkillEntry(
|
||||||
cfg: ClawdisConfig,
|
cfg: ClawdisConfig,
|
||||||
skillKey: string,
|
skillKey: string,
|
||||||
@ -104,7 +119,7 @@ export async function setupSkills(
|
|||||||
...installable.map((skill) => ({
|
...installable.map((skill) => ({
|
||||||
value: skill.name,
|
value: skill.name,
|
||||||
label: `${skill.emoji ?? "🧩"} ${skill.name}`,
|
label: `${skill.emoji ?? "🧩"} ${skill.name}`,
|
||||||
hint: skill.install[0]?.label ?? "install",
|
hint: formatSkillHint(skill),
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -77,7 +77,7 @@ describe("sessionsCommand", () => {
|
|||||||
|
|
||||||
it("shows placeholder rows when tokens are missing", async () => {
|
it("shows placeholder rows when tokens are missing", async () => {
|
||||||
const store = writeStore({
|
const store = writeStore({
|
||||||
"group:demo": {
|
"discord:group:demo": {
|
||||||
sessionId: "xyz",
|
sessionId: "xyz",
|
||||||
updatedAt: Date.now() - 5 * 60_000,
|
updatedAt: Date.now() - 5 * 60_000,
|
||||||
thinkingLevel: "high",
|
thinkingLevel: "high",
|
||||||
@ -89,7 +89,7 @@ describe("sessionsCommand", () => {
|
|||||||
|
|
||||||
fs.rmSync(store);
|
fs.rmSync(store);
|
||||||
|
|
||||||
const row = logs.find((line) => line.includes("group:demo")) ?? "";
|
const row = logs.find((line) => line.includes("discord:group:demo")) ?? "";
|
||||||
expect(row).toContain("-".padEnd(20));
|
expect(row).toContain("-".padEnd(20));
|
||||||
expect(row).toContain("think:high");
|
expect(row).toContain("think:high");
|
||||||
expect(row).toContain("5m ago");
|
expect(row).toContain("5m ago");
|
||||||
|
|||||||
@ -119,10 +119,17 @@ const formatAge = (ms: number | null | undefined) => {
|
|||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
function classifyKey(key: string): SessionRow["kind"] {
|
function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] {
|
||||||
if (key === "global") return "global";
|
if (key === "global") return "global";
|
||||||
if (key.startsWith("group:")) return "group";
|
|
||||||
if (key === "unknown") return "unknown";
|
if (key === "unknown") return "unknown";
|
||||||
|
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||||
|
if (
|
||||||
|
key.startsWith("group:") ||
|
||||||
|
key.includes(":group:") ||
|
||||||
|
key.includes(":channel:")
|
||||||
|
) {
|
||||||
|
return "group";
|
||||||
|
}
|
||||||
return "direct";
|
return "direct";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +139,7 @@ function toRows(store: Record<string, SessionEntry>): SessionRow[] {
|
|||||||
const updatedAt = entry?.updatedAt ?? null;
|
const updatedAt = entry?.updatedAt ?? null;
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
kind: classifyKey(key),
|
kind: classifyKey(key, entry),
|
||||||
updatedAt,
|
updatedAt,
|
||||||
ageMs: updatedAt ? Date.now() - updatedAt : null,
|
ageMs: updatedAt ? Date.now() - updatedAt : null,
|
||||||
sessionId: entry?.sessionId,
|
sessionId: entry?.sessionId,
|
||||||
|
|||||||
@ -102,7 +102,7 @@ export async function getStatusSummary(): Promise<StatusSummary> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
kind: classifyKey(key),
|
kind: classifyKey(key, entry),
|
||||||
sessionId: entry?.sessionId,
|
sessionId: entry?.sessionId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
age,
|
age,
|
||||||
@ -169,10 +169,20 @@ const formatContextUsage = (
|
|||||||
return `tokens: ${formatKTokens(used)} used, ${formatKTokens(left)} left of ${formatKTokens(contextTokens)} (${pctLabel})`;
|
return `tokens: ${formatKTokens(used)} used, ${formatKTokens(left)} left of ${formatKTokens(contextTokens)} (${pctLabel})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const classifyKey = (key: string): SessionStatus["kind"] => {
|
const classifyKey = (
|
||||||
|
key: string,
|
||||||
|
entry?: SessionEntry,
|
||||||
|
): SessionStatus["kind"] => {
|
||||||
if (key === "global") return "global";
|
if (key === "global") return "global";
|
||||||
if (key.startsWith("group:")) return "group";
|
|
||||||
if (key === "unknown") return "unknown";
|
if (key === "unknown") return "unknown";
|
||||||
|
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||||
|
if (
|
||||||
|
key.startsWith("group:") ||
|
||||||
|
key.includes(":group:") ||
|
||||||
|
key.includes(":channel:")
|
||||||
|
) {
|
||||||
|
return "group";
|
||||||
|
}
|
||||||
return "direct";
|
return "direct";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -206,6 +206,64 @@ describe("config identity defaults", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("config discord", () => {
|
||||||
|
let previousHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
previousHome = process.env.HOME;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.HOME = previousHome;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads discord guild map + dm group settings", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const configDir = path.join(home, ".clawdis");
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(configDir, "clawdis.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
discord: {
|
||||||
|
enabled: true,
|
||||||
|
dm: {
|
||||||
|
enabled: true,
|
||||||
|
allowFrom: ["steipete"],
|
||||||
|
groupEnabled: true,
|
||||||
|
groupChannels: ["clawd-dm"],
|
||||||
|
},
|
||||||
|
guilds: {
|
||||||
|
"123": {
|
||||||
|
slug: "friends-of-clawd",
|
||||||
|
requireMention: false,
|
||||||
|
users: ["steipete"],
|
||||||
|
channels: {
|
||||||
|
general: { allow: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
const { loadConfig } = await import("./config.js");
|
||||||
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
expect(cfg.discord?.enabled).toBe(true);
|
||||||
|
expect(cfg.discord?.dm?.groupEnabled).toBe(true);
|
||||||
|
expect(cfg.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
|
||||||
|
expect(cfg.discord?.guilds?.["123"]?.slug).toBe("friends-of-clawd");
|
||||||
|
expect(cfg.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Nix integration (U3, U5, U9)", () => {
|
describe("Nix integration (U3, U5, U9)", () => {
|
||||||
describe("U3: isNixMode env var detection", () => {
|
describe("U3: isNixMode env var detection", () => {
|
||||||
it("isNixMode is false when CLAWDIS_NIX_MODE is not set", async () => {
|
it("isNixMode is false when CLAWDIS_NIX_MODE is not set", async () => {
|
||||||
@ -430,3 +488,48 @@ describe("talk.voiceAliases", () => {
|
|||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("legacy config detection", () => {
|
||||||
|
it("rejects routing.allowFrom", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { validateConfigObject } = await import("./config.js");
|
||||||
|
const res = validateConfigObject({
|
||||||
|
routing: { allowFrom: ["+15555550123"] },
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
expect(res.issues[0]?.path).toBe("routing.allowFrom");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const { migrateLegacyConfig } = await import("./config.js");
|
||||||
|
const res = migrateLegacyConfig({
|
||||||
|
routing: { allowFrom: ["+15555550123"] },
|
||||||
|
});
|
||||||
|
expect(res.changes).toContain("Moved routing.allowFrom → whatsapp.allowFrom.");
|
||||||
|
expect(res.config?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||||
|
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces legacy issues in snapshot", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const configPath = path.join(home, ".clawdis", "clawdis.json");
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
const { readConfigFileSnapshot } = await import("./config.js");
|
||||||
|
const snap = await readConfigFileSnapshot();
|
||||||
|
|
||||||
|
expect(snap.valid).toBe(false);
|
||||||
|
expect(snap.legacyIssues.length).toBe(1);
|
||||||
|
expect(snap.legacyIssues[0]?.path).toBe("routing.allowFrom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -58,6 +58,11 @@ export type WebConfig = {
|
|||||||
reconnect?: WebReconnectConfig;
|
reconnect?: WebReconnectConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WhatsAppConfig = {
|
||||||
|
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||||
|
allowFrom?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type BrowserConfig = {
|
export type BrowserConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||||
@ -164,21 +169,52 @@ export type TelegramConfig = {
|
|||||||
webhookPath?: string;
|
webhookPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DiscordDmConfig = {
|
||||||
|
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Allowlist for DM senders (ids or names). */
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
/** If true, allow group DMs (default: false). */
|
||||||
|
groupEnabled?: boolean;
|
||||||
|
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||||
|
groupChannels?: Array<string | number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordGuildChannelConfig = {
|
||||||
|
allow?: boolean;
|
||||||
|
requireMention?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordGuildEntry = {
|
||||||
|
slug?: string;
|
||||||
|
requireMention?: boolean;
|
||||||
|
users?: Array<string | number>;
|
||||||
|
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordSlashCommandConfig = {
|
||||||
|
/** Enable handling for the configured slash command (default: false). */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Slash command name (default: "clawd"). */
|
||||||
|
name?: string;
|
||||||
|
/** Session key prefix for slash commands (default: "discord:slash"). */
|
||||||
|
sessionPrefix?: string;
|
||||||
|
/** Reply ephemerally (default: true). */
|
||||||
|
ephemeral?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type DiscordConfig = {
|
export type DiscordConfig = {
|
||||||
/** If false, do not start the Discord provider. Default: true. */
|
/** If false, do not start the Discord provider. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
token?: string;
|
token?: string;
|
||||||
allowFrom?: Array<string | number>;
|
|
||||||
guildAllowFrom?: {
|
|
||||||
guilds?: Array<string | number>;
|
|
||||||
users?: Array<string | number>;
|
|
||||||
};
|
|
||||||
requireMention?: boolean;
|
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Number of recent guild messages to include for context (default: 20). */
|
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
/** Allow agent-triggered Discord reactions (default: true). */
|
/** Allow agent-triggered Discord reactions (default: true). */
|
||||||
enableReactions?: boolean;
|
enableReactions?: boolean;
|
||||||
|
slashCommand?: DiscordSlashCommandConfig;
|
||||||
|
dm?: DiscordDmConfig;
|
||||||
|
/** New per-guild config keyed by guild id or slug. */
|
||||||
|
guilds?: Record<string, DiscordGuildEntry>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignalConfig = {
|
export type SignalConfig = {
|
||||||
@ -241,7 +277,6 @@ export type GroupChatConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type RoutingConfig = {
|
export type RoutingConfig = {
|
||||||
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
|
||||||
transcribeAudio?: {
|
transcribeAudio?: {
|
||||||
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
|
||||||
command: string[];
|
command: string[];
|
||||||
@ -316,6 +351,8 @@ export type GatewayAuthMode = "token" | "password";
|
|||||||
export type GatewayAuthConfig = {
|
export type GatewayAuthConfig = {
|
||||||
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
||||||
mode?: GatewayAuthMode;
|
mode?: GatewayAuthMode;
|
||||||
|
/** Shared token for token mode (stored locally for CLI auth). */
|
||||||
|
token?: string;
|
||||||
/** Shared password for password mode (consider env instead). */
|
/** Shared password for password mode (consider env instead). */
|
||||||
password?: string;
|
password?: string;
|
||||||
/** Allow Tailscale identity headers when serve mode is enabled. */
|
/** Allow Tailscale identity headers when serve mode is enabled. */
|
||||||
@ -506,6 +543,7 @@ export type ClawdisConfig = {
|
|||||||
messages?: MessagesConfig;
|
messages?: MessagesConfig;
|
||||||
session?: SessionConfig;
|
session?: SessionConfig;
|
||||||
web?: WebConfig;
|
web?: WebConfig;
|
||||||
|
whatsapp?: WhatsAppConfig;
|
||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
discord?: DiscordConfig;
|
discord?: DiscordConfig;
|
||||||
signal?: SignalConfig;
|
signal?: SignalConfig;
|
||||||
@ -674,7 +712,6 @@ const HeartbeatSchema = z
|
|||||||
|
|
||||||
const RoutingSchema = z
|
const RoutingSchema = z
|
||||||
.object({
|
.object({
|
||||||
allowFrom: z.array(z.string()).optional(),
|
|
||||||
groupChat: GroupChatSchema,
|
groupChat: GroupChatSchema,
|
||||||
transcribeAudio: TranscribeAudioSchema,
|
transcribeAudio: TranscribeAudioSchema,
|
||||||
queue: z
|
queue: z
|
||||||
@ -890,6 +927,11 @@ const ClawdisSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
whatsapp: z
|
||||||
|
.object({
|
||||||
|
allowFrom: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
telegram: z
|
telegram: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
@ -908,17 +950,61 @@ const ClawdisSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
token: z.string().optional(),
|
token: z.string().optional(),
|
||||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
slashCommand: z
|
||||||
guildAllowFrom: z
|
|
||||||
.object({
|
.object({
|
||||||
guilds: z.array(z.union([z.string(), z.number()])).optional(),
|
enabled: z.boolean().optional(),
|
||||||
users: z.array(z.union([z.string(), z.number()])).optional(),
|
name: z.string().optional(),
|
||||||
|
sessionPrefix: z.string().optional(),
|
||||||
|
ephemeral: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
requireMention: z.boolean().optional(),
|
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
enableReactions: z.boolean().optional(),
|
enableReactions: z.boolean().optional(),
|
||||||
|
dm: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
groupEnabled: z.boolean().optional(),
|
||||||
|
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
guilds: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
slug: z.string().optional(),
|
||||||
|
requireMention: z.boolean().optional(),
|
||||||
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
channels: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
allow: z.boolean().optional(),
|
||||||
|
requireMention: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
guild: z
|
||||||
|
.object({
|
||||||
|
allowFrom: z
|
||||||
|
.object({
|
||||||
|
guilds: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
channels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
requireMention: z.boolean().optional(),
|
||||||
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
signal: z
|
signal: z
|
||||||
@ -1013,6 +1099,7 @@ const ClawdisSchema = z.object({
|
|||||||
auth: z
|
auth: z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
|
||||||
|
token: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
allowTailscale: z.boolean().optional(),
|
allowTailscale: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
@ -1076,6 +1163,11 @@ export type ConfigValidationIssue = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LegacyConfigIssue = {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConfigFileSnapshot = {
|
export type ConfigFileSnapshot = {
|
||||||
path: string;
|
path: string;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
@ -1084,8 +1176,100 @@ export type ConfigFileSnapshot = {
|
|||||||
valid: boolean;
|
valid: boolean;
|
||||||
config: ClawdisConfig;
|
config: ClawdisConfig;
|
||||||
issues: ConfigValidationIssue[];
|
issues: ConfigValidationIssue[];
|
||||||
|
legacyIssues: LegacyConfigIssue[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LegacyConfigRule = {
|
||||||
|
path: string[];
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LegacyConfigMigration = {
|
||||||
|
id: string;
|
||||||
|
describe: string;
|
||||||
|
apply: (raw: Record<string, unknown>, changes: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||||
|
{
|
||||||
|
path: ["routing", "allowFrom"],
|
||||||
|
message:
|
||||||
|
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||||
|
{
|
||||||
|
id: "routing.allowFrom->whatsapp.allowFrom",
|
||||||
|
describe: "Move routing.allowFrom to whatsapp.allowFrom",
|
||||||
|
apply: (raw, changes) => {
|
||||||
|
const routing = raw.routing;
|
||||||
|
if (!routing || typeof routing !== "object") return;
|
||||||
|
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
||||||
|
if (allowFrom === undefined) return;
|
||||||
|
|
||||||
|
const whatsapp =
|
||||||
|
raw.whatsapp && typeof raw.whatsapp === "object"
|
||||||
|
? (raw.whatsapp as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (whatsapp.allowFrom === undefined) {
|
||||||
|
whatsapp.allowFrom = allowFrom;
|
||||||
|
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
|
||||||
|
} else {
|
||||||
|
changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set).");
|
||||||
|
}
|
||||||
|
|
||||||
|
delete (routing as Record<string, unknown>).allowFrom;
|
||||||
|
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||||
|
delete raw.routing;
|
||||||
|
}
|
||||||
|
raw.whatsapp = whatsapp;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||||
|
if (!raw || typeof raw !== "object") return [];
|
||||||
|
const root = raw as Record<string, unknown>;
|
||||||
|
const issues: LegacyConfigIssue[] = [];
|
||||||
|
for (const rule of LEGACY_CONFIG_RULES) {
|
||||||
|
let cursor: unknown = root;
|
||||||
|
for (const key of rule.path) {
|
||||||
|
if (!cursor || typeof cursor !== "object") {
|
||||||
|
cursor = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = (cursor as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
if (cursor !== undefined) {
|
||||||
|
issues.push({ path: rule.path.join("."), message: rule.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateLegacyConfig(raw: unknown): {
|
||||||
|
config: ClawdisConfig | null;
|
||||||
|
changes: string[];
|
||||||
|
} {
|
||||||
|
if (!raw || typeof raw !== "object") return { config: null, changes: [] };
|
||||||
|
const next = structuredClone(raw) as Record<string, unknown>;
|
||||||
|
const changes: string[] = [];
|
||||||
|
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
|
||||||
|
migration.apply(next, changes);
|
||||||
|
}
|
||||||
|
if (changes.length === 0) return { config: null, changes: [] };
|
||||||
|
const validated = validateConfigObject(next);
|
||||||
|
if (!validated.ok) {
|
||||||
|
changes.push(
|
||||||
|
"Migration applied, but config still invalid; fix remaining issues manually.",
|
||||||
|
);
|
||||||
|
return { config: null, changes };
|
||||||
|
}
|
||||||
|
return { config: validated.config, changes };
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegExp(text: string): string {
|
function escapeRegExp(text: string): string {
|
||||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
@ -1144,6 +1328,16 @@ export function validateConfigObject(
|
|||||||
):
|
):
|
||||||
| { ok: true; config: ClawdisConfig }
|
| { ok: true; config: ClawdisConfig }
|
||||||
| { ok: false; issues: ConfigValidationIssue[] } {
|
| { ok: false; issues: ConfigValidationIssue[] } {
|
||||||
|
const legacyIssues = findLegacyConfigIssues(raw);
|
||||||
|
if (legacyIssues.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
issues: legacyIssues.map((iss) => ({
|
||||||
|
path: iss.path,
|
||||||
|
message: iss.message,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
const validated = ClawdisSchema.safeParse(raw);
|
const validated = ClawdisSchema.safeParse(raw);
|
||||||
if (!validated.success) {
|
if (!validated.success) {
|
||||||
return {
|
return {
|
||||||
@ -1216,6 +1410,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
const exists = fs.existsSync(configPath);
|
const exists = fs.existsSync(configPath);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
const config = applyTalkApiKey({});
|
const config = applyTalkApiKey({});
|
||||||
|
const legacyIssues: LegacyConfigIssue[] = [];
|
||||||
return {
|
return {
|
||||||
path: configPath,
|
path: configPath,
|
||||||
exists: false,
|
exists: false,
|
||||||
@ -1224,6 +1419,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
valid: true,
|
valid: true,
|
||||||
config,
|
config,
|
||||||
issues: [],
|
issues: [],
|
||||||
|
legacyIssues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1241,9 +1437,12 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
issues: [
|
issues: [
|
||||||
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
|
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
|
||||||
],
|
],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const legacyIssues = findLegacyConfigIssues(parsedRes.parsed);
|
||||||
|
|
||||||
const validated = validateConfigObject(parsedRes.parsed);
|
const validated = validateConfigObject(parsedRes.parsed);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return {
|
return {
|
||||||
@ -1254,6 +1453,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
valid: false,
|
valid: false,
|
||||||
config: {},
|
config: {},
|
||||||
issues: validated.issues,
|
issues: validated.issues,
|
||||||
|
legacyIssues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1265,6 +1465,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
valid: true,
|
valid: true,
|
||||||
config: applyTalkApiKey(validated.config),
|
config: applyTalkApiKey(validated.config),
|
||||||
issues: [],
|
issues: [],
|
||||||
|
legacyIssues,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
@ -1275,6 +1476,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
|||||||
valid: false,
|
valid: false,
|
||||||
config: {},
|
config: {},
|
||||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildGroupDisplayName,
|
||||||
deriveSessionKey,
|
deriveSessionKey,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveSessionKey,
|
resolveSessionKey,
|
||||||
@ -31,6 +32,38 @@ describe("sessions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefixes group keys with surface when available", () => {
|
||||||
|
expect(
|
||||||
|
deriveSessionKey("per-sender", {
|
||||||
|
From: "12345-678@g.us",
|
||||||
|
ChatType: "group",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
}),
|
||||||
|
).toBe("whatsapp:group:12345-678@g.us");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit surface when provided in group key", () => {
|
||||||
|
expect(
|
||||||
|
resolveSessionKey(
|
||||||
|
"per-sender",
|
||||||
|
{ From: "group:discord:12345", ChatType: "group" },
|
||||||
|
"main",
|
||||||
|
),
|
||||||
|
).toBe("discord:group:12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds discord display name with guild+channel slugs", () => {
|
||||||
|
expect(
|
||||||
|
buildGroupDisplayName({
|
||||||
|
surface: "discord",
|
||||||
|
room: "#general",
|
||||||
|
space: "friends-of-clawd",
|
||||||
|
id: "123",
|
||||||
|
key: "discord:group:123",
|
||||||
|
}),
|
||||||
|
).toBe("discord:friends-of-clawd#general");
|
||||||
|
});
|
||||||
|
|
||||||
it("collapses direct chats to main by default", () => {
|
it("collapses direct chats to main by default", () => {
|
||||||
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main");
|
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,11 +10,24 @@ import { normalizeE164 } from "../utils.js";
|
|||||||
|
|
||||||
export type SessionScope = "per-sender" | "global";
|
export type SessionScope = "per-sender" | "global";
|
||||||
|
|
||||||
|
const GROUP_SURFACES = new Set([
|
||||||
|
"whatsapp",
|
||||||
|
"telegram",
|
||||||
|
"discord",
|
||||||
|
"signal",
|
||||||
|
"imessage",
|
||||||
|
"webchat",
|
||||||
|
"slack",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type SessionChatType = "direct" | "group" | "room";
|
||||||
|
|
||||||
export type SessionEntry = {
|
export type SessionEntry = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
abortedLastRun?: boolean;
|
abortedLastRun?: boolean;
|
||||||
|
chatType?: SessionChatType;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
providerOverride?: string;
|
providerOverride?: string;
|
||||||
@ -27,6 +40,11 @@ export type SessionEntry = {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
|
displayName?: string;
|
||||||
|
surface?: string;
|
||||||
|
subject?: string;
|
||||||
|
room?: string;
|
||||||
|
space?: string;
|
||||||
lastChannel?:
|
lastChannel?:
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@ -38,6 +56,14 @@ export type SessionEntry = {
|
|||||||
skillsSnapshot?: SessionSkillSnapshot;
|
skillsSnapshot?: SessionSkillSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GroupKeyResolution = {
|
||||||
|
key: string;
|
||||||
|
legacyKey?: string;
|
||||||
|
surface?: string;
|
||||||
|
id?: string;
|
||||||
|
chatType?: SessionChatType;
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionSkillSnapshot = {
|
export type SessionSkillSnapshot = {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||||
@ -66,6 +92,151 @@ export function resolveStorePath(store?: string) {
|
|||||||
return path.resolve(store);
|
return path.resolve(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeGroupLabel(raw?: string) {
|
||||||
|
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const dashed = trimmed.replace(/\s+/g, "-");
|
||||||
|
const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-");
|
||||||
|
return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenGroupId(value?: string) {
|
||||||
|
const trimmed = value?.trim() ?? "";
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.length <= 14) return trimmed;
|
||||||
|
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGroupDisplayName(params: {
|
||||||
|
surface?: string;
|
||||||
|
subject?: string;
|
||||||
|
room?: string;
|
||||||
|
space?: string;
|
||||||
|
id?: string;
|
||||||
|
key: string;
|
||||||
|
}) {
|
||||||
|
const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim();
|
||||||
|
const room = params.room?.trim();
|
||||||
|
const space = params.space?.trim();
|
||||||
|
const subject = params.subject?.trim();
|
||||||
|
const detail =
|
||||||
|
(room && space
|
||||||
|
? `${space}${room.startsWith("#") ? "" : "#"}${room}`
|
||||||
|
: room || subject || space || "") || "";
|
||||||
|
const fallbackId = params.id?.trim() || params.key.replace(/^group:/, "");
|
||||||
|
const rawLabel = detail || fallbackId;
|
||||||
|
let token = normalizeGroupLabel(rawLabel);
|
||||||
|
if (!token) {
|
||||||
|
token = normalizeGroupLabel(shortenGroupId(rawLabel));
|
||||||
|
}
|
||||||
|
if (!params.room && token.startsWith("#")) {
|
||||||
|
token = token.replace(/^#+/, "");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
token &&
|
||||||
|
!/^[@#]/.test(token) &&
|
||||||
|
!token.startsWith("g-") &&
|
||||||
|
!token.includes("#")
|
||||||
|
) {
|
||||||
|
token = `g-${token}`;
|
||||||
|
}
|
||||||
|
return token ? `${surfaceKey}:${token}` : surfaceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGroupSessionKey(
|
||||||
|
ctx: MsgContext,
|
||||||
|
): GroupKeyResolution | null {
|
||||||
|
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
|
||||||
|
if (!from) return null;
|
||||||
|
const chatType = ctx.ChatType?.trim().toLowerCase();
|
||||||
|
const isGroup =
|
||||||
|
chatType === "group" ||
|
||||||
|
from.startsWith("group:") ||
|
||||||
|
from.includes("@g.us") ||
|
||||||
|
from.includes(":group:") ||
|
||||||
|
from.includes(":channel:");
|
||||||
|
if (!isGroup) return null;
|
||||||
|
|
||||||
|
const surfaceHint = ctx.Surface?.trim().toLowerCase();
|
||||||
|
const hasLegacyGroupPrefix = from.startsWith("group:");
|
||||||
|
const raw = (
|
||||||
|
hasLegacyGroupPrefix ? from.slice("group:".length) : from
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
let surface: string | undefined;
|
||||||
|
let kind: "group" | "channel" | undefined;
|
||||||
|
let id = "";
|
||||||
|
|
||||||
|
const parseKind = (value: string) => {
|
||||||
|
if (value === "channel") return "channel";
|
||||||
|
return "group";
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseParts = (parts: string[]) => {
|
||||||
|
if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) {
|
||||||
|
surface = parts[0];
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const kindCandidate = parts[1];
|
||||||
|
if (["group", "channel"].includes(kindCandidate)) {
|
||||||
|
kind = parseKind(kindCandidate);
|
||||||
|
id = parts.slice(2).join(":");
|
||||||
|
} else {
|
||||||
|
id = parts.slice(1).join(":");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
id = parts[1];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) {
|
||||||
|
kind = parseKind(parts[0]);
|
||||||
|
id = parts.slice(1).join(":");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasLegacyGroupPrefix) {
|
||||||
|
const legacyParts = raw.split(":").filter(Boolean);
|
||||||
|
if (legacyParts.length > 1) {
|
||||||
|
parseParts(legacyParts);
|
||||||
|
} else {
|
||||||
|
id = raw;
|
||||||
|
}
|
||||||
|
} else if (from.includes("@g.us") && !from.includes(":")) {
|
||||||
|
id = from;
|
||||||
|
} else {
|
||||||
|
parseParts(from.split(":").filter(Boolean));
|
||||||
|
if (!id) {
|
||||||
|
id = raw || from;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSurface = surface ?? surfaceHint;
|
||||||
|
if (!resolvedSurface) {
|
||||||
|
const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`;
|
||||||
|
return {
|
||||||
|
key: legacy,
|
||||||
|
id: raw || from,
|
||||||
|
legacyKey: legacy,
|
||||||
|
chatType: "group",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedKind = kind === "channel" ? "channel" : "group";
|
||||||
|
const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`;
|
||||||
|
let legacyKey: string | undefined;
|
||||||
|
if (hasLegacyGroupPrefix || from.includes("@g.us")) {
|
||||||
|
legacyKey = `group:${id || raw || from}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
legacyKey,
|
||||||
|
surface: resolvedSurface,
|
||||||
|
id: id || raw || from,
|
||||||
|
chatType: resolvedKind === "channel" ? "room" : "group",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function loadSessionStore(
|
export function loadSessionStore(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
): Record<string, SessionEntry> {
|
): Record<string, SessionEntry> {
|
||||||
@ -145,6 +316,12 @@ export async function updateLastRoute(params: {
|
|||||||
totalTokens: existing?.totalTokens,
|
totalTokens: existing?.totalTokens,
|
||||||
model: existing?.model,
|
model: existing?.model,
|
||||||
contextTokens: existing?.contextTokens,
|
contextTokens: existing?.contextTokens,
|
||||||
|
displayName: existing?.displayName,
|
||||||
|
chatType: existing?.chatType,
|
||||||
|
surface: existing?.surface,
|
||||||
|
subject: existing?.subject,
|
||||||
|
room: existing?.room,
|
||||||
|
space: existing?.space,
|
||||||
skillsSnapshot: existing?.skillsSnapshot,
|
skillsSnapshot: existing?.skillsSnapshot,
|
||||||
lastChannel: channel,
|
lastChannel: channel,
|
||||||
lastTo: to?.trim() ? to.trim() : undefined,
|
lastTo: to?.trim() ? to.trim() : undefined,
|
||||||
@ -157,14 +334,9 @@ export async function updateLastRoute(params: {
|
|||||||
// Decide which session bucket to use (per-sender vs global).
|
// Decide which session bucket to use (per-sender vs global).
|
||||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||||
if (scope === "global") return "global";
|
if (scope === "global") return "global";
|
||||||
|
const resolvedGroup = resolveGroupSessionKey(ctx);
|
||||||
|
if (resolvedGroup) return resolvedGroup.key;
|
||||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
||||||
// Preserve group conversations as distinct buckets
|
|
||||||
if (typeof ctx.From === "string" && ctx.From.includes("@g.us")) {
|
|
||||||
return `group:${ctx.From}`;
|
|
||||||
}
|
|
||||||
if (typeof ctx.From === "string" && ctx.From.startsWith("group:")) {
|
|
||||||
return ctx.From;
|
|
||||||
}
|
|
||||||
return from || "unknown";
|
return from || "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,11 +349,16 @@ export function resolveSessionKey(
|
|||||||
ctx: MsgContext,
|
ctx: MsgContext,
|
||||||
mainKey?: string,
|
mainKey?: string,
|
||||||
) {
|
) {
|
||||||
|
const explicit = ctx.SessionKey?.trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
const raw = deriveSessionKey(scope, ctx);
|
const raw = deriveSessionKey(scope, ctx);
|
||||||
if (scope === "global") return raw;
|
if (scope === "global") return raw;
|
||||||
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
||||||
const canonical = (mainKey ?? "main").trim() || "main";
|
const canonical = (mainKey ?? "main").trim() || "main";
|
||||||
const isGroup = raw.startsWith("group:") || raw.includes("@g.us");
|
const isGroup =
|
||||||
|
raw.startsWith("group:") ||
|
||||||
|
raw.includes(":group:") ||
|
||||||
|
raw.includes(":channel:");
|
||||||
if (!isGroup) return canonical;
|
if (!isGroup) return canonical;
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,7 +103,7 @@ function resolveDeliveryTarget(
|
|||||||
|
|
||||||
const sanitizedWhatsappTo = (() => {
|
const sanitizedWhatsappTo = (() => {
|
||||||
if (channel !== "whatsapp") return to;
|
if (channel !== "whatsapp") return to;
|
||||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||||
if (rawAllow.includes("*")) return to;
|
if (rawAllow.includes("*")) return to;
|
||||||
const allowFrom = rawAllow
|
const allowFrom = rawAllow
|
||||||
.map((val) => normalizeE164(val))
|
.map((val) => normalizeE164(val))
|
||||||
|
|||||||
@ -23,4 +23,21 @@ describe("cron schedule", () => {
|
|||||||
);
|
);
|
||||||
expect(next).toBe(anchor + 30_000);
|
expect(next).toBe(anchor + 30_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("computes next run for every schedule when anchorMs is not provided", () => {
|
||||||
|
const now = Date.parse("2025-12-13T00:00:00.000Z");
|
||||||
|
const next = computeNextRunAtMs({ kind: "every", everyMs: 30_000 }, now);
|
||||||
|
|
||||||
|
// Should return nowMs + everyMs, not nowMs (which would cause infinite loop)
|
||||||
|
expect(next).toBe(now + 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances when now matches anchor for every schedule", () => {
|
||||||
|
const anchor = Date.parse("2025-12-13T00:00:00.000Z");
|
||||||
|
const next = computeNextRunAtMs(
|
||||||
|
{ kind: "every", everyMs: 30_000, anchorMs: anchor },
|
||||||
|
anchor,
|
||||||
|
);
|
||||||
|
expect(next).toBe(anchor + 30_000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,9 +12,9 @@ export function computeNextRunAtMs(
|
|||||||
if (schedule.kind === "every") {
|
if (schedule.kind === "every") {
|
||||||
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
|
const everyMs = Math.max(1, Math.floor(schedule.everyMs));
|
||||||
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
|
const anchor = Math.max(0, Math.floor(schedule.anchorMs ?? nowMs));
|
||||||
if (nowMs <= anchor) return anchor;
|
if (nowMs < anchor) return anchor;
|
||||||
const elapsed = nowMs - anchor;
|
const elapsed = nowMs - anchor;
|
||||||
const steps = Math.floor((elapsed + everyMs - 1) / everyMs);
|
const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs));
|
||||||
return anchor + steps * everyMs;
|
return anchor + steps * everyMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
150
src/discord/monitor.test.ts
Normal file
150
src/discord/monitor.test.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
allowListMatches,
|
||||||
|
type DiscordGuildEntryResolved,
|
||||||
|
normalizeDiscordAllowList,
|
||||||
|
normalizeDiscordSlug,
|
||||||
|
resolveDiscordChannelConfig,
|
||||||
|
resolveDiscordGuildEntry,
|
||||||
|
resolveGroupDmAllow,
|
||||||
|
} from "./monitor.js";
|
||||||
|
|
||||||
|
const fakeGuild = (id: string, name: string) =>
|
||||||
|
({ id, name }) as unknown as import("discord.js").Guild;
|
||||||
|
|
||||||
|
const makeEntries = (
|
||||||
|
entries: Record<string, Partial<DiscordGuildEntryResolved>>,
|
||||||
|
): Record<string, DiscordGuildEntryResolved> => {
|
||||||
|
const out: Record<string, DiscordGuildEntryResolved> = {};
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
out[key] = {
|
||||||
|
slug: value.slug,
|
||||||
|
requireMention: value.requireMention,
|
||||||
|
users: value.users,
|
||||||
|
channels: value.channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("discord allowlist helpers", () => {
|
||||||
|
it("normalizes slugs", () => {
|
||||||
|
expect(normalizeDiscordSlug("Friends of Clawd")).toBe("friends-of-clawd");
|
||||||
|
expect(normalizeDiscordSlug("#General")).toBe("general");
|
||||||
|
expect(normalizeDiscordSlug("Dev__Chat")).toBe("dev-chat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches ids or names", () => {
|
||||||
|
const allow = normalizeDiscordAllowList(
|
||||||
|
["123", "steipete", "Friends of Clawd"],
|
||||||
|
["discord:", "user:", "guild:", "channel:"],
|
||||||
|
);
|
||||||
|
expect(allow).not.toBeNull();
|
||||||
|
if (!allow) {
|
||||||
|
throw new Error("Expected allow list to be normalized");
|
||||||
|
}
|
||||||
|
expect(allowListMatches(allow, { id: "123" })).toBe(true);
|
||||||
|
expect(allowListMatches(allow, { name: "steipete" })).toBe(true);
|
||||||
|
expect(allowListMatches(allow, { name: "friends-of-clawd" })).toBe(true);
|
||||||
|
expect(allowListMatches(allow, { name: "other" })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("discord guild/channel resolution", () => {
|
||||||
|
it("resolves guild entry by id", () => {
|
||||||
|
const guildEntries = makeEntries({
|
||||||
|
"123": { slug: "friends-of-clawd" },
|
||||||
|
});
|
||||||
|
const resolved = resolveDiscordGuildEntry({
|
||||||
|
guild: fakeGuild("123", "Friends of Clawd"),
|
||||||
|
guildEntries,
|
||||||
|
});
|
||||||
|
expect(resolved?.id).toBe("123");
|
||||||
|
expect(resolved?.slug).toBe("friends-of-clawd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves guild entry by slug key", () => {
|
||||||
|
const guildEntries = makeEntries({
|
||||||
|
"friends-of-clawd": { slug: "friends-of-clawd" },
|
||||||
|
});
|
||||||
|
const resolved = resolveDiscordGuildEntry({
|
||||||
|
guild: fakeGuild("123", "Friends of Clawd"),
|
||||||
|
guildEntries,
|
||||||
|
});
|
||||||
|
expect(resolved?.id).toBe("123");
|
||||||
|
expect(resolved?.slug).toBe("friends-of-clawd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves channel config by slug", () => {
|
||||||
|
const guildInfo: DiscordGuildEntryResolved = {
|
||||||
|
channels: {
|
||||||
|
general: { allow: true },
|
||||||
|
help: { allow: true, requireMention: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const channel = resolveDiscordChannelConfig({
|
||||||
|
guildInfo,
|
||||||
|
channelId: "456",
|
||||||
|
channelName: "General",
|
||||||
|
channelSlug: "general",
|
||||||
|
});
|
||||||
|
expect(channel?.allowed).toBe(true);
|
||||||
|
expect(channel?.requireMention).toBeUndefined();
|
||||||
|
|
||||||
|
const help = resolveDiscordChannelConfig({
|
||||||
|
guildInfo,
|
||||||
|
channelId: "789",
|
||||||
|
channelName: "Help",
|
||||||
|
channelSlug: "help",
|
||||||
|
});
|
||||||
|
expect(help?.allowed).toBe(true);
|
||||||
|
expect(help?.requireMention).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies channel when config present but no match", () => {
|
||||||
|
const guildInfo: DiscordGuildEntryResolved = {
|
||||||
|
channels: {
|
||||||
|
general: { allow: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const channel = resolveDiscordChannelConfig({
|
||||||
|
guildInfo,
|
||||||
|
channelId: "999",
|
||||||
|
channelName: "random",
|
||||||
|
channelSlug: "random",
|
||||||
|
});
|
||||||
|
expect(channel?.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("discord group DM gating", () => {
|
||||||
|
it("allows all when no allowlist", () => {
|
||||||
|
expect(
|
||||||
|
resolveGroupDmAllow({
|
||||||
|
channels: undefined,
|
||||||
|
channelId: "1",
|
||||||
|
channelName: "dm",
|
||||||
|
channelSlug: "dm",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches group DM allowlist", () => {
|
||||||
|
expect(
|
||||||
|
resolveGroupDmAllow({
|
||||||
|
channels: ["clawd-dm"],
|
||||||
|
channelId: "1",
|
||||||
|
channelName: "Clawd DM",
|
||||||
|
channelSlug: "clawd-dm",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
resolveGroupDmAllow({
|
||||||
|
channels: ["clawd-dm"],
|
||||||
|
channelId: "1",
|
||||||
|
channelName: "Other",
|
||||||
|
channelSlug: "other",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
ApplicationCommandOptionType,
|
||||||
|
ChannelType,
|
||||||
Client,
|
Client,
|
||||||
|
type CommandInteractionOption,
|
||||||
Events,
|
Events,
|
||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
type Message,
|
type Message,
|
||||||
@ -9,10 +12,12 @@ import {
|
|||||||
import { chunkText } from "../auto-reply/chunk.js";
|
import { chunkText } from "../auto-reply/chunk.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
|
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
|
import type { DiscordSlashCommandConfig } from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
||||||
import { danger, isVerbose, logVerbose } from "../globals.js";
|
import { danger, isVerbose, logVerbose, warn } from "../globals.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
@ -24,12 +29,7 @@ export type MonitorDiscordOpts = {
|
|||||||
token?: string;
|
token?: string;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
allowFrom?: Array<string | number>;
|
slashCommand?: DiscordSlashCommandConfig;
|
||||||
guildAllowFrom?: {
|
|
||||||
guilds?: Array<string | number>;
|
|
||||||
users?: Array<string | number>;
|
|
||||||
};
|
|
||||||
requireMention?: boolean;
|
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
};
|
};
|
||||||
@ -47,6 +47,25 @@ type DiscordHistoryEntry = {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DiscordAllowList = {
|
||||||
|
allowAll: boolean;
|
||||||
|
ids: Set<string>;
|
||||||
|
names: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordGuildEntryResolved = {
|
||||||
|
id?: string;
|
||||||
|
slug?: string;
|
||||||
|
requireMention?: boolean;
|
||||||
|
users?: Array<string | number>;
|
||||||
|
channels?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscordChannelConfigResolved = {
|
||||||
|
allowed: boolean;
|
||||||
|
requireMention?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const token = normalizeDiscordToken(
|
const token = normalizeDiscordToken(
|
||||||
@ -69,16 +88,21 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom;
|
const dmConfig = cfg.discord?.dm;
|
||||||
const guildAllowFrom = opts.guildAllowFrom ?? cfg.discord?.guildAllowFrom;
|
const guildEntries = cfg.discord?.guilds;
|
||||||
const requireMention =
|
const allowFrom = dmConfig?.allowFrom;
|
||||||
opts.requireMention ?? cfg.discord?.requireMention ?? true;
|
const slashCommand = resolveSlashCommandConfig(
|
||||||
|
opts.slashCommand ?? cfg.discord?.slashCommand,
|
||||||
|
);
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||||
const historyLimit = Math.max(
|
const historyLimit = Math.max(
|
||||||
0,
|
0,
|
||||||
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
|
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
|
||||||
);
|
);
|
||||||
|
const dmEnabled = dmConfig?.enabled ?? true;
|
||||||
|
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||||
|
const groupDmChannels = dmConfig?.groupChannels;
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
@ -95,6 +119,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
client.once(Events.ClientReady, () => {
|
client.once(Events.ClientReady, () => {
|
||||||
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
|
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
|
||||||
|
if (slashCommand.enabled) {
|
||||||
|
void ensureSlashCommand(client, slashCommand, runtime);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.Error, (err) => {
|
client.on(Events.Error, (err) => {
|
||||||
@ -106,7 +133,13 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
if (message.author?.bot) return;
|
if (message.author?.bot) return;
|
||||||
if (!message.author) return;
|
if (!message.author) return;
|
||||||
|
|
||||||
const isDirectMessage = !message.guild;
|
// Discord.js typing excludes GroupDM for message.channel.type; widen for runtime check.
|
||||||
|
const channelType = message.channel.type as ChannelType;
|
||||||
|
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||||
|
const isDirectMessage = channelType === ChannelType.DM;
|
||||||
|
const isGuildMessage = Boolean(message.guild);
|
||||||
|
if (isGroupDm && !groupDmEnabled) return;
|
||||||
|
if (isDirectMessage && !dmEnabled) return;
|
||||||
const botId = client.user?.id;
|
const botId = client.user?.id;
|
||||||
const wasMentioned =
|
const wasMentioned =
|
||||||
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
|
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
|
||||||
@ -117,7 +150,59 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
message.embeds[0]?.description ||
|
message.embeds[0]?.description ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
if (!isDirectMessage && historyLimit > 0 && baseText) {
|
const guildInfo = isGuildMessage
|
||||||
|
? resolveDiscordGuildEntry({
|
||||||
|
guild: message.guild,
|
||||||
|
guildEntries,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
isGuildMessage &&
|
||||||
|
guildEntries &&
|
||||||
|
Object.keys(guildEntries).length > 0 &&
|
||||||
|
!guildInfo
|
||||||
|
) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked discord guild ${message.guild?.id ?? "unknown"} (not in discord.guilds)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelName =
|
||||||
|
(isGuildMessage || isGroupDm) && "name" in message.channel
|
||||||
|
? message.channel.name
|
||||||
|
: undefined;
|
||||||
|
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||||
|
const guildSlug =
|
||||||
|
guildInfo?.slug ||
|
||||||
|
(message.guild?.name ? normalizeDiscordSlug(message.guild.name) : "");
|
||||||
|
const channelConfig = isGuildMessage
|
||||||
|
? resolveDiscordChannelConfig({
|
||||||
|
guildInfo,
|
||||||
|
channelId: message.channelId,
|
||||||
|
channelName,
|
||||||
|
channelSlug,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const groupDmAllowed =
|
||||||
|
isGroupDm &&
|
||||||
|
resolveGroupDmAllow({
|
||||||
|
channels: groupDmChannels,
|
||||||
|
channelId: message.channelId,
|
||||||
|
channelName,
|
||||||
|
channelSlug,
|
||||||
|
});
|
||||||
|
if (isGroupDm && !groupDmAllowed) return;
|
||||||
|
|
||||||
|
if (isGuildMessage && channelConfig?.allowed === false) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked discord channel ${message.channelId} not in guild channel allowlist`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGuildMessage && historyLimit > 0 && baseText) {
|
||||||
const history = guildHistories.get(message.channelId) ?? [];
|
const history = guildHistories.get(message.channelId) ?? [];
|
||||||
history.push({
|
history.push({
|
||||||
sender: message.member?.displayName ?? message.author.tag,
|
sender: message.member?.displayName ?? message.author.tag,
|
||||||
@ -129,7 +214,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
guildHistories.set(message.channelId, history);
|
guildHistories.set(message.channelId, history);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDirectMessage && requireMention) {
|
const resolvedRequireMention =
|
||||||
|
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
|
||||||
|
if (isGuildMessage && resolvedRequireMention) {
|
||||||
if (botId && !wasMentioned) {
|
if (botId && !wasMentioned) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@ -142,23 +229,23 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDirectMessage && guildAllowFrom) {
|
if (isGuildMessage) {
|
||||||
const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [
|
const userAllow = guildInfo?.users;
|
||||||
"guild:",
|
if (Array.isArray(userAllow) && userAllow.length > 0) {
|
||||||
]);
|
const users = normalizeDiscordAllowList(userAllow, [
|
||||||
const users = normalizeDiscordAllowList(guildAllowFrom.users, [
|
"discord:",
|
||||||
"discord:",
|
"user:",
|
||||||
"user:",
|
]);
|
||||||
]);
|
const userOk =
|
||||||
if (guilds || users) {
|
!users ||
|
||||||
const guildId = message.guild?.id ?? "";
|
allowListMatches(users, {
|
||||||
const userId = message.author.id;
|
id: message.author.id,
|
||||||
const guildOk =
|
name: message.author.username,
|
||||||
!guilds || guilds.allowAll || (guildId && guilds.ids.has(guildId));
|
tag: message.author.tag,
|
||||||
const userOk = !users || users.allowAll || users.ids.has(userId);
|
});
|
||||||
if (!guildOk || !userOk) {
|
if (!userOk) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Blocked discord guild sender ${userId} (guild ${guildId || "unknown"}) not in guildAllowFrom`,
|
`Blocked discord guild sender ${message.author.id} (not in guild users allowlist)`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -166,22 +253,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
const allowed = allowFrom
|
const allowList = normalizeDiscordAllowList(allowFrom, [
|
||||||
.map((entry) => String(entry).trim())
|
"discord:",
|
||||||
.filter(Boolean);
|
"user:",
|
||||||
const candidate = message.author.id;
|
]);
|
||||||
const normalized = new Set(
|
|
||||||
allowed
|
|
||||||
.filter((entry) => entry !== "*")
|
|
||||||
.map((entry) => entry.replace(/^discord:/i, "")),
|
|
||||||
);
|
|
||||||
const permitted =
|
const permitted =
|
||||||
allowed.includes("*") ||
|
allowList &&
|
||||||
normalized.has(candidate) ||
|
allowListMatches(allowList, {
|
||||||
allowed.includes(candidate);
|
id: message.author.id,
|
||||||
|
name: message.author.username,
|
||||||
|
tag: message.author.tag,
|
||||||
|
});
|
||||||
if (!permitted) {
|
if (!permitted) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Blocked unauthorized discord sender ${candidate} (not in allowFrom)`,
|
`Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -198,6 +283,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const fromLabel = isDirectMessage
|
const fromLabel = isDirectMessage
|
||||||
? buildDirectLabel(message)
|
? buildDirectLabel(message)
|
||||||
: buildGuildLabel(message);
|
: buildGuildLabel(message);
|
||||||
|
const groupRoom =
|
||||||
|
isGuildMessage && channelSlug ? `#${channelSlug}` : undefined;
|
||||||
|
const groupSubject = isDirectMessage ? undefined : groupRoom;
|
||||||
const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`;
|
const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`;
|
||||||
let combinedBody = formatAgentEnvelope({
|
let combinedBody = formatAgentEnvelope({
|
||||||
surface: "Discord",
|
surface: "Discord",
|
||||||
@ -224,7 +312,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
combinedBody = `[Chat messages since your last reply - for context]\n${historyText}\n\n[Current message - respond to this]\n${combinedBody}`;
|
combinedBody = `[Chat messages since your last reply - for context]\n${historyText}\n\n[Current message - respond to this]\n${combinedBody}`;
|
||||||
}
|
}
|
||||||
combinedBody = `${combinedBody}\n[from: ${message.member?.displayName ?? message.author.tag}]`;
|
const name = message.author.tag;
|
||||||
|
const id = message.author.id;
|
||||||
|
combinedBody = `${combinedBody}\n[from: ${name} id:${id}]`;
|
||||||
shouldClearHistory = true;
|
shouldClearHistory = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,10 +328,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
: `channel:${message.channelId}`,
|
: `channel:${message.channelId}`,
|
||||||
ChatType: isDirectMessage ? "direct" : "group",
|
ChatType: isDirectMessage ? "direct" : "group",
|
||||||
SenderName: message.member?.displayName ?? message.author.tag,
|
SenderName: message.member?.displayName ?? message.author.tag,
|
||||||
GroupSubject:
|
GroupSubject: groupSubject,
|
||||||
!isDirectMessage && "name" in message.channel
|
GroupRoom: groupRoom,
|
||||||
? message.channel.name
|
GroupSpace: isGuildMessage ? guildSlug || undefined : undefined,
|
||||||
: undefined,
|
|
||||||
Surface: "discord" as const,
|
Surface: "discord" as const,
|
||||||
WasMentioned: wasMentioned,
|
WasMentioned: wasMentioned,
|
||||||
MessageSid: message.id,
|
MessageSid: message.id,
|
||||||
@ -290,7 +379,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
token,
|
token,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
if (!isDirectMessage && shouldClearHistory && historyLimit > 0) {
|
if (isGuildMessage && shouldClearHistory && historyLimit > 0) {
|
||||||
guildHistories.set(message.channelId, []);
|
guildHistories.set(message.channelId, []);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -298,6 +387,163 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
client.on(Events.InteractionCreate, async (interaction) => {
|
||||||
|
try {
|
||||||
|
if (!slashCommand.enabled) return;
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
if (interaction.commandName !== slashCommand.name) return;
|
||||||
|
if (interaction.user?.bot) return;
|
||||||
|
|
||||||
|
const channelType = interaction.channel?.type as ChannelType | undefined;
|
||||||
|
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||||
|
const isDirectMessage =
|
||||||
|
!interaction.inGuild() && channelType === ChannelType.DM;
|
||||||
|
const isGuildMessage = interaction.inGuild();
|
||||||
|
|
||||||
|
if (isGroupDm && !groupDmEnabled) return;
|
||||||
|
if (isDirectMessage && !dmEnabled) return;
|
||||||
|
|
||||||
|
if (isGuildMessage) {
|
||||||
|
const guildInfo = resolveDiscordGuildEntry({
|
||||||
|
guild: interaction.guild ?? null,
|
||||||
|
guildEntries,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
guildEntries &&
|
||||||
|
Object.keys(guildEntries).length > 0 &&
|
||||||
|
!guildInfo
|
||||||
|
) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked discord guild ${interaction.guildId ?? "unknown"} (not in discord.guilds)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channelName =
|
||||||
|
interaction.channel && "name" in interaction.channel
|
||||||
|
? interaction.channel.name
|
||||||
|
: undefined;
|
||||||
|
const channelSlug = channelName
|
||||||
|
? normalizeDiscordSlug(channelName)
|
||||||
|
: "";
|
||||||
|
const channelConfig = resolveDiscordChannelConfig({
|
||||||
|
guildInfo,
|
||||||
|
channelId: interaction.channelId,
|
||||||
|
channelName,
|
||||||
|
channelSlug,
|
||||||
|
});
|
||||||
|
if (channelConfig?.allowed === false) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked discord channel ${interaction.channelId} not in guild channel allowlist`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userAllow = guildInfo?.users;
|
||||||
|
if (Array.isArray(userAllow) && userAllow.length > 0) {
|
||||||
|
const users = normalizeDiscordAllowList(userAllow, [
|
||||||
|
"discord:",
|
||||||
|
"user:",
|
||||||
|
]);
|
||||||
|
const userOk =
|
||||||
|
!users ||
|
||||||
|
allowListMatches(users, {
|
||||||
|
id: interaction.user.id,
|
||||||
|
name: interaction.user.username,
|
||||||
|
tag: interaction.user.tag,
|
||||||
|
});
|
||||||
|
if (!userOk) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked discord guild sender ${interaction.user.id} (not in guild users allowlist)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isGroupDm) {
|
||||||
|
const channelName =
|
||||||
|
interaction.channel && "name" in interaction.channel
|
||||||
|
? interaction.channel.name
|
||||||
|
: undefined;
|
||||||
|
const channelSlug = channelName
|
||||||
|
? normalizeDiscordSlug(channelName)
|
||||||
|
: "";
|
||||||
|
const groupDmAllowed = resolveGroupDmAllow({
|
||||||
|
channels: groupDmChannels,
|
||||||
|
channelId: interaction.channelId,
|
||||||
|
channelName,
|
||||||
|
channelSlug,
|
||||||
|
});
|
||||||
|
if (!groupDmAllowed) return;
|
||||||
|
} else if (isDirectMessage) {
|
||||||
|
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
|
const allowList = normalizeDiscordAllowList(allowFrom, [
|
||||||
|
"discord:",
|
||||||
|
"user:",
|
||||||
|
]);
|
||||||
|
const permitted =
|
||||||
|
allowList &&
|
||||||
|
allowListMatches(allowList, {
|
||||||
|
id: interaction.user.id,
|
||||||
|
name: interaction.user.username,
|
||||||
|
tag: interaction.user.tag,
|
||||||
|
});
|
||||||
|
if (!permitted) {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked unauthorized discord sender ${interaction.user.id} (not in allowFrom)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = resolveSlashPrompt(interaction.options.data);
|
||||||
|
if (!prompt) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "Message required.",
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({ ephemeral: slashCommand.ephemeral });
|
||||||
|
|
||||||
|
const userId = interaction.user.id;
|
||||||
|
const ctxPayload = {
|
||||||
|
Body: prompt,
|
||||||
|
From: `discord:${userId}`,
|
||||||
|
To: `slash:${userId}`,
|
||||||
|
ChatType: "direct",
|
||||||
|
SenderName: interaction.user.username,
|
||||||
|
Surface: "discord" as const,
|
||||||
|
WasMentioned: true,
|
||||||
|
MessageSid: interaction.id,
|
||||||
|
Timestamp: interaction.createdTimestamp,
|
||||||
|
SessionKey: `${slashCommand.sessionPrefix}:${userId}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg);
|
||||||
|
const replies = replyResult
|
||||||
|
? Array.isArray(replyResult)
|
||||||
|
? replyResult
|
||||||
|
: [replyResult]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
await deliverSlashReplies({
|
||||||
|
replies,
|
||||||
|
interaction,
|
||||||
|
ephemeral: slashCommand.ephemeral,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error?.(danger(`slash handler failed: ${String(err)}`));
|
||||||
|
if (interaction.isRepliable()) {
|
||||||
|
const content = "Sorry, something went wrong handling that command.";
|
||||||
|
if (interaction.deferred || interaction.replied) {
|
||||||
|
await interaction.followUp({ content, ephemeral: true });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content, ephemeral: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await client.login(token);
|
await client.login(token);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@ -364,25 +610,256 @@ function buildGuildLabel(message: import("discord.js").Message) {
|
|||||||
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
|
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDiscordAllowList(
|
export function normalizeDiscordAllowList(
|
||||||
raw: Array<string | number> | undefined,
|
raw: Array<string | number> | undefined,
|
||||||
prefixes: string[],
|
prefixes: string[],
|
||||||
): { allowAll: boolean; ids: Set<string> } | null {
|
): DiscordAllowList | null {
|
||||||
if (!raw || raw.length === 0) return null;
|
if (!raw || raw.length === 0) return null;
|
||||||
const cleaned = raw
|
const ids = new Set<string>();
|
||||||
.map((entry) => String(entry).trim())
|
const names = new Set<string>();
|
||||||
.filter(Boolean)
|
let allowAll = false;
|
||||||
.map((entry) => {
|
|
||||||
for (const prefix of prefixes) {
|
for (const rawEntry of raw) {
|
||||||
if (entry.toLowerCase().startsWith(prefix)) {
|
let entry = String(rawEntry).trim();
|
||||||
return entry.slice(prefix.length);
|
if (!entry) continue;
|
||||||
}
|
if (entry === "*") {
|
||||||
|
allowAll = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
if (entry.toLowerCase().startsWith(prefix)) {
|
||||||
|
entry = entry.slice(prefix.length);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return entry;
|
}
|
||||||
|
const mentionMatch = entry.match(/^<[@#][!]?(\d+)>$/);
|
||||||
|
if (mentionMatch?.[1]) {
|
||||||
|
ids.add(mentionMatch[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry = entry.trim();
|
||||||
|
if (entry.startsWith("@") || entry.startsWith("#")) {
|
||||||
|
entry = entry.slice(1);
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(entry)) {
|
||||||
|
ids.add(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = normalizeDiscordName(entry);
|
||||||
|
if (normalized) names.add(normalized);
|
||||||
|
const slugged = normalizeDiscordSlug(entry);
|
||||||
|
if (slugged) names.add(slugged);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowAll && ids.size === 0 && names.size === 0) return null;
|
||||||
|
return { allowAll, ids, names };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDiscordName(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDiscordSlug(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
let text = value.trim().toLowerCase();
|
||||||
|
if (!text) return "";
|
||||||
|
text = text.replace(/^[@#]+/, "");
|
||||||
|
text = text.replace(/[\s_]+/g, "-");
|
||||||
|
text = text.replace(/[^a-z0-9-]+/g, "-");
|
||||||
|
text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allowListMatches(
|
||||||
|
allowList: DiscordAllowList,
|
||||||
|
candidates: {
|
||||||
|
id?: string;
|
||||||
|
name?: string | null;
|
||||||
|
tag?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (allowList.allowAll) return true;
|
||||||
|
const { id, name, tag } = candidates;
|
||||||
|
if (id && allowList.ids.has(id)) return true;
|
||||||
|
const normalizedName = normalizeDiscordName(name);
|
||||||
|
if (normalizedName && allowList.names.has(normalizedName)) return true;
|
||||||
|
const normalizedTag = normalizeDiscordName(tag);
|
||||||
|
if (normalizedTag && allowList.names.has(normalizedTag)) return true;
|
||||||
|
const slugName = normalizeDiscordSlug(name);
|
||||||
|
if (slugName && allowList.names.has(slugName)) return true;
|
||||||
|
const slugTag = normalizeDiscordSlug(tag);
|
||||||
|
if (slugTag && allowList.names.has(slugTag)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDiscordGuildEntry(params: {
|
||||||
|
guild: import("discord.js").Guild | null;
|
||||||
|
guildEntries: Record<string, DiscordGuildEntryResolved> | undefined;
|
||||||
|
}): DiscordGuildEntryResolved | null {
|
||||||
|
const { guild, guildEntries } = params;
|
||||||
|
if (!guild || !guildEntries || Object.keys(guildEntries).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const guildId = guild.id;
|
||||||
|
const guildSlug = normalizeDiscordSlug(guild.name);
|
||||||
|
const direct = guildEntries[guildId];
|
||||||
|
if (direct) {
|
||||||
|
return {
|
||||||
|
id: guildId,
|
||||||
|
slug: direct.slug ?? guildSlug,
|
||||||
|
requireMention: direct.requireMention,
|
||||||
|
users: direct.users,
|
||||||
|
channels: direct.channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (guildSlug && guildEntries[guildSlug]) {
|
||||||
|
const entry = guildEntries[guildSlug];
|
||||||
|
return {
|
||||||
|
id: guildId,
|
||||||
|
slug: entry.slug ?? guildSlug,
|
||||||
|
requireMention: entry.requireMention,
|
||||||
|
users: entry.users,
|
||||||
|
channels: entry.channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const matchBySlug = Object.entries(guildEntries).find(([, entry]) => {
|
||||||
|
const entrySlug = normalizeDiscordSlug(entry.slug);
|
||||||
|
return entrySlug && entrySlug === guildSlug;
|
||||||
|
});
|
||||||
|
if (matchBySlug) {
|
||||||
|
const entry = matchBySlug[1];
|
||||||
|
return {
|
||||||
|
id: guildId,
|
||||||
|
slug: entry.slug ?? guildSlug,
|
||||||
|
requireMention: entry.requireMention,
|
||||||
|
users: entry.users,
|
||||||
|
channels: entry.channels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDiscordChannelConfig(params: {
|
||||||
|
guildInfo: DiscordGuildEntryResolved | null;
|
||||||
|
channelId: string;
|
||||||
|
channelName?: string;
|
||||||
|
channelSlug?: string;
|
||||||
|
}): DiscordChannelConfigResolved | null {
|
||||||
|
const { guildInfo, channelId, channelName, channelSlug } = params;
|
||||||
|
const channelEntries = guildInfo?.channels;
|
||||||
|
if (channelEntries && Object.keys(channelEntries).length > 0) {
|
||||||
|
const entry =
|
||||||
|
channelEntries[channelId] ??
|
||||||
|
(channelSlug
|
||||||
|
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
||||||
|
: undefined) ??
|
||||||
|
(channelName
|
||||||
|
? channelEntries[normalizeDiscordSlug(channelName)]
|
||||||
|
: undefined);
|
||||||
|
if (!entry) return { allowed: false };
|
||||||
|
return {
|
||||||
|
allowed: entry.allow !== false,
|
||||||
|
requireMention: entry.requireMention,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGroupDmAllow(params: {
|
||||||
|
channels: Array<string | number> | undefined;
|
||||||
|
channelId: string;
|
||||||
|
channelName?: string;
|
||||||
|
channelSlug?: string;
|
||||||
|
}) {
|
||||||
|
const { channels, channelId, channelName, channelSlug } = params;
|
||||||
|
if (!channels || channels.length === 0) return true;
|
||||||
|
const allowList = normalizeDiscordAllowList(channels, ["channel:"]);
|
||||||
|
if (!allowList) return true;
|
||||||
|
return allowListMatches(allowList, {
|
||||||
|
id: channelId,
|
||||||
|
name: channelSlug || channelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSlashCommand(
|
||||||
|
client: Client,
|
||||||
|
slashCommand: Required<DiscordSlashCommandConfig>,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const appCommands = client.application?.commands;
|
||||||
|
if (!appCommands) {
|
||||||
|
runtime.error?.(danger("discord slash commands unavailable"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = await appCommands.fetch();
|
||||||
|
const hasCommand = Array.from(existing.values()).some(
|
||||||
|
(entry) => entry.name === slashCommand.name,
|
||||||
|
);
|
||||||
|
if (hasCommand) return;
|
||||||
|
await appCommands.create({
|
||||||
|
name: slashCommand.name,
|
||||||
|
description: "Ask Clawdis a question",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "prompt",
|
||||||
|
description: "What should Clawdis help with?",
|
||||||
|
type: ApplicationCommandOptionType.String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
const allowAll = cleaned.includes("*");
|
runtime.log?.(`registered discord slash command /${slashCommand.name}`);
|
||||||
const ids = new Set(cleaned.filter((entry) => entry !== "*"));
|
} catch (err) {
|
||||||
return { allowAll, ids };
|
const status = (err as { status?: number | string })?.status;
|
||||||
|
const code = (err as { code?: number | string })?.code;
|
||||||
|
const message = String(err);
|
||||||
|
const isRateLimit =
|
||||||
|
status === 429 || code === 429 || /rate ?limit/i.test(message);
|
||||||
|
const text = `discord slash command setup failed: ${message}`;
|
||||||
|
if (isRateLimit) {
|
||||||
|
logVerbose(text);
|
||||||
|
runtime.error?.(warn(text));
|
||||||
|
} else {
|
||||||
|
runtime.error?.(danger(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSlashCommandConfig(
|
||||||
|
raw: DiscordSlashCommandConfig | undefined,
|
||||||
|
): Required<DiscordSlashCommandConfig> {
|
||||||
|
return {
|
||||||
|
enabled: raw ? raw.enabled !== false : false,
|
||||||
|
name: raw?.name?.trim() || "clawd",
|
||||||
|
sessionPrefix: raw?.sessionPrefix?.trim() || "discord:slash",
|
||||||
|
ephemeral: raw?.ephemeral !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSlashPrompt(
|
||||||
|
options: readonly CommandInteractionOption[],
|
||||||
|
): string | undefined {
|
||||||
|
const direct = findFirstStringOption(options);
|
||||||
|
if (direct) return direct;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstStringOption(
|
||||||
|
options: readonly CommandInteractionOption[],
|
||||||
|
): string | undefined {
|
||||||
|
for (const option of options) {
|
||||||
|
if (typeof option.value === "string") {
|
||||||
|
const trimmed = option.value.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
}
|
||||||
|
if (option.options && option.options.length > 0) {
|
||||||
|
const nested = findFirstStringOption(option.options);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendTyping(message: Message) {
|
async function sendTyping(message: Message) {
|
||||||
@ -430,3 +907,45 @@ async function deliverReplies({
|
|||||||
runtime.log?.(`delivered reply to ${target}`);
|
runtime.log?.(`delivered reply to ${target}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deliverSlashReplies({
|
||||||
|
replies,
|
||||||
|
interaction,
|
||||||
|
ephemeral,
|
||||||
|
}: {
|
||||||
|
replies: ReplyPayload[];
|
||||||
|
interaction: import("discord.js").ChatInputCommandInteraction;
|
||||||
|
ephemeral: boolean;
|
||||||
|
}) {
|
||||||
|
const messages: string[] = [];
|
||||||
|
for (const payload of replies) {
|
||||||
|
const textRaw = payload.text?.trim() ?? "";
|
||||||
|
const text =
|
||||||
|
textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined;
|
||||||
|
const mediaList =
|
||||||
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
|
const combined = [
|
||||||
|
text ?? "",
|
||||||
|
...mediaList.map((url) => url.trim()).filter(Boolean),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
if (!combined) continue;
|
||||||
|
for (const chunk of chunkText(combined, 2000)) {
|
||||||
|
messages.push(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: "No response was generated for that command.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first, ...rest] = messages;
|
||||||
|
await interaction.editReply({ content: first });
|
||||||
|
for (const message of rest) {
|
||||||
|
await interaction.followUp({ content: message, ephemeral });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
|||||||
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||||
if (auth.mode === "token" && !auth.token) {
|
if (auth.mode === "token" && !auth.token) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set",
|
"gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDIS_GATEWAY_TOKEN)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (auth.mode === "password" && !auth.password) {
|
if (auth.mode === "password" && !auth.password) {
|
||||||
|
|||||||
@ -25,8 +25,8 @@ export async function callGateway<T = unknown>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const remote =
|
const isRemoteMode = config.gateway?.mode === "remote";
|
||||||
config.gateway?.mode === "remote" ? config.gateway.remote : undefined;
|
const remote = isRemoteMode ? config.gateway.remote : undefined;
|
||||||
const url =
|
const url =
|
||||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
(typeof opts.url === "string" && opts.url.trim().length > 0
|
||||||
? opts.url.trim()
|
? opts.url.trim()
|
||||||
@ -39,9 +39,15 @@ export async function callGateway<T = unknown>(
|
|||||||
(typeof opts.token === "string" && opts.token.trim().length > 0
|
(typeof opts.token === "string" && opts.token.trim().length > 0
|
||||||
? opts.token.trim()
|
? opts.token.trim()
|
||||||
: undefined) ||
|
: undefined) ||
|
||||||
(typeof remote?.token === "string" && remote.token.trim().length > 0
|
(isRemoteMode
|
||||||
? remote.token.trim()
|
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||||
: undefined);
|
? remote.token.trim()
|
||||||
|
: undefined
|
||||||
|
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
||||||
|
(typeof config.gateway?.auth?.token === "string" &&
|
||||||
|
config.gateway.auth.token.trim().length > 0
|
||||||
|
? config.gateway.auth.token.trim()
|
||||||
|
: undefined));
|
||||||
const password =
|
const password =
|
||||||
(typeof opts.password === "string" && opts.password.trim().length > 0
|
(typeof opts.password === "string" && opts.password.trim().length > 0
|
||||||
? opts.password.trim()
|
? opts.password.trim()
|
||||||
|
|||||||
@ -130,6 +130,11 @@ let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
|||||||
let testGatewayAuth: Record<string, unknown> | undefined;
|
let testGatewayAuth: Record<string, unknown> | undefined;
|
||||||
let testHooksConfig: Record<string, unknown> | undefined;
|
let testHooksConfig: Record<string, unknown> | undefined;
|
||||||
let testCanvasHostPort: number | undefined;
|
let testCanvasHostPort: number | undefined;
|
||||||
|
let testLegacyIssues: Array<{ path: string; message: string }> = [];
|
||||||
|
let testLegacyParsed: Record<string, unknown> = {};
|
||||||
|
let testMigrationConfig: Record<string, unknown> | null = null;
|
||||||
|
let testMigrationChanges: string[] = [];
|
||||||
|
let testIsNixMode = false;
|
||||||
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||||
vi.mock("../config/sessions.js", async () => {
|
vi.mock("../config/sessions.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||||
@ -151,6 +156,21 @@ vi.mock("../config/config.js", () => {
|
|||||||
path.join(os.homedir(), ".clawdis", "clawdis.json");
|
path.join(os.homedir(), ".clawdis", "clawdis.json");
|
||||||
|
|
||||||
const readConfigFileSnapshot = async () => {
|
const readConfigFileSnapshot = async () => {
|
||||||
|
if (testLegacyIssues.length > 0) {
|
||||||
|
return {
|
||||||
|
path: resolveConfigPath(),
|
||||||
|
exists: true,
|
||||||
|
raw: JSON.stringify(testLegacyParsed ?? {}),
|
||||||
|
parsed: testLegacyParsed ?? {},
|
||||||
|
valid: false,
|
||||||
|
config: {},
|
||||||
|
issues: testLegacyIssues.map((issue) => ({
|
||||||
|
path: issue.path,
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
legacyIssues: testLegacyIssues,
|
||||||
|
};
|
||||||
|
}
|
||||||
const configPath = resolveConfigPath();
|
const configPath = resolveConfigPath();
|
||||||
try {
|
try {
|
||||||
await fs.access(configPath);
|
await fs.access(configPath);
|
||||||
@ -163,6 +183,7 @@ vi.mock("../config/config.js", () => {
|
|||||||
valid: true,
|
valid: true,
|
||||||
config: {},
|
config: {},
|
||||||
issues: [],
|
issues: [],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -176,6 +197,7 @@ vi.mock("../config/config.js", () => {
|
|||||||
valid: true,
|
valid: true,
|
||||||
config: parsed,
|
config: parsed,
|
||||||
issues: [],
|
issues: [],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
@ -186,27 +208,32 @@ vi.mock("../config/config.js", () => {
|
|||||||
valid: false,
|
valid: false,
|
||||||
config: {},
|
config: {},
|
||||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||||
|
legacyIssues: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeConfigFile = async (cfg: Record<string, unknown>) => {
|
const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
|
||||||
const configPath = resolveConfigPath();
|
const configPath = resolveConfigPath();
|
||||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
|
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
|
||||||
await fs.writeFile(configPath, raw, "utf-8");
|
await fs.writeFile(configPath, raw, "utf-8");
|
||||||
};
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
||||||
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
|
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
|
||||||
isNixMode: false,
|
isNixMode: testIsNixMode,
|
||||||
|
migrateLegacyConfig: (raw: unknown) => ({
|
||||||
|
config: testMigrationConfig ?? (raw as Record<string, unknown>),
|
||||||
|
changes: testMigrationChanges,
|
||||||
|
}),
|
||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
agent: {
|
agent: {
|
||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||||
},
|
},
|
||||||
routing: {
|
whatsapp: {
|
||||||
allowFrom: testAllowFrom,
|
allowFrom: testAllowFrom,
|
||||||
},
|
},
|
||||||
session: { mainKey: "main", store: testSessionStorePath },
|
session: { mainKey: "main", store: testSessionStorePath },
|
||||||
@ -279,6 +306,11 @@ beforeEach(async () => {
|
|||||||
testGatewayAuth = undefined;
|
testGatewayAuth = undefined;
|
||||||
testHooksConfig = undefined;
|
testHooksConfig = undefined;
|
||||||
testCanvasHostPort = undefined;
|
testCanvasHostPort = undefined;
|
||||||
|
testLegacyIssues = [];
|
||||||
|
testLegacyParsed = {};
|
||||||
|
testMigrationConfig = null;
|
||||||
|
testMigrationChanges = [];
|
||||||
|
testIsNixMode = false;
|
||||||
cronIsolatedRun.mockClear();
|
cronIsolatedRun.mockClear();
|
||||||
drainSystemEvents();
|
drainSystemEvents();
|
||||||
resetAgentRunContextForTest();
|
resetAgentRunContextForTest();
|
||||||
@ -516,6 +548,40 @@ describe("gateway server", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test("auto-migrates legacy config on startup", async () => {
|
||||||
|
(writeConfigFile as unknown as { mockClear?: () => void })?.mockClear?.();
|
||||||
|
testLegacyIssues = [
|
||||||
|
{
|
||||||
|
path: "routing.allowFrom",
|
||||||
|
message: "legacy",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
|
||||||
|
testMigrationConfig = { whatsapp: { allowFrom: ["+15555550123"] } };
|
||||||
|
testMigrationChanges = ["Moved routing.allowFrom → whatsapp.allowFrom."];
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port);
|
||||||
|
expect(writeConfigFile).toHaveBeenCalledWith(testMigrationConfig);
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails in Nix mode when legacy config is present", async () => {
|
||||||
|
testLegacyIssues = [
|
||||||
|
{
|
||||||
|
path: "routing.allowFrom",
|
||||||
|
message: "legacy",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
|
||||||
|
testIsNixMode = true;
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
await expect(startGatewayServer(port)).rejects.toThrow(
|
||||||
|
"Legacy config entries detected while running in Nix mode",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("models.list returns model catalog", async () => {
|
test("models.list returns model catalog", async () => {
|
||||||
piSdkMock.enabled = true;
|
piSdkMock.enabled = true;
|
||||||
piSdkMock.models = [
|
piSdkMock.models = [
|
||||||
@ -3865,7 +3931,7 @@ describe("gateway server", () => {
|
|||||||
thinkingLevel: "low",
|
thinkingLevel: "low",
|
||||||
verboseLevel: "on",
|
verboseLevel: "on",
|
||||||
},
|
},
|
||||||
"group:dev": {
|
"discord:group:dev": {
|
||||||
sessionId: "sess-group",
|
sessionId: "sess-group",
|
||||||
updatedAt: now - 120_000,
|
updatedAt: now - 120_000,
|
||||||
totalTokens: 50,
|
totalTokens: 50,
|
||||||
@ -3977,7 +4043,7 @@ describe("gateway server", () => {
|
|||||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
||||||
ws,
|
ws,
|
||||||
"sessions.delete",
|
"sessions.delete",
|
||||||
{ key: "group:dev" },
|
{ key: "discord:group:dev" },
|
||||||
);
|
);
|
||||||
expect(deleted.ok).toBe(true);
|
expect(deleted.ok).toBe(true);
|
||||||
expect(deleted.payload?.deleted).toBe(true);
|
expect(deleted.payload?.deleted).toBe(true);
|
||||||
@ -3986,7 +4052,9 @@ describe("gateway server", () => {
|
|||||||
}>(ws, "sessions.list", {});
|
}>(ws, "sessions.list", {});
|
||||||
expect(listAfterDelete.ok).toBe(true);
|
expect(listAfterDelete.ok).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev"),
|
listAfterDelete.payload?.sessions.some(
|
||||||
|
(s) => s.key === "discord:group:dev",
|
||||||
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
const filesAfterDelete = await fs.readdir(dir);
|
const filesAfterDelete = await fs.readdir(dir);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import {
|
|||||||
CONFIG_PATH_CLAWDIS,
|
CONFIG_PATH_CLAWDIS,
|
||||||
isNixMode,
|
isNixMode,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
migrateLegacyConfig,
|
||||||
parseConfigJson5,
|
parseConfigJson5,
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
STATE_DIR_CLAWDIS,
|
STATE_DIR_CLAWDIS,
|
||||||
@ -55,6 +56,7 @@ import {
|
|||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
buildGroupDisplayName,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
@ -455,6 +457,11 @@ type GatewaySessionsDefaults = {
|
|||||||
type GatewaySessionRow = {
|
type GatewaySessionRow = {
|
||||||
key: string;
|
key: string;
|
||||||
kind: "direct" | "group" | "global" | "unknown";
|
kind: "direct" | "group" | "global" | "unknown";
|
||||||
|
displayName?: string;
|
||||||
|
surface?: string;
|
||||||
|
subject?: string;
|
||||||
|
room?: string;
|
||||||
|
space?: string;
|
||||||
updatedAt: number | null;
|
updatedAt: number | null;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
@ -653,7 +660,6 @@ type DedupeEntry = {
|
|||||||
error?: ErrorShape;
|
error?: ErrorShape;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
|
|
||||||
|
|
||||||
function formatForLog(value: unknown): string {
|
function formatForLog(value: unknown): string {
|
||||||
try {
|
try {
|
||||||
@ -862,13 +868,41 @@ function loadSessionEntry(sessionKey: string) {
|
|||||||
return { cfg, storePath, store, entry };
|
return { cfg, storePath, store, entry };
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifySessionKey(key: string): GatewaySessionRow["kind"] {
|
function classifySessionKey(
|
||||||
|
key: string,
|
||||||
|
entry?: SessionEntry,
|
||||||
|
): GatewaySessionRow["kind"] {
|
||||||
if (key === "global") return "global";
|
if (key === "global") return "global";
|
||||||
if (key.startsWith("group:")) return "group";
|
|
||||||
if (key === "unknown") return "unknown";
|
if (key === "unknown") return "unknown";
|
||||||
|
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
||||||
|
if (
|
||||||
|
key.startsWith("group:") ||
|
||||||
|
key.includes(":group:") ||
|
||||||
|
key.includes(":channel:")
|
||||||
|
) {
|
||||||
|
return "group";
|
||||||
|
}
|
||||||
return "direct";
|
return "direct";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseGroupKey(
|
||||||
|
key: string,
|
||||||
|
): { surface?: string; kind?: "group" | "channel"; id?: string } | null {
|
||||||
|
if (key.startsWith("group:")) {
|
||||||
|
const raw = key.slice("group:".length);
|
||||||
|
return raw ? { id: raw } : null;
|
||||||
|
}
|
||||||
|
const parts = key.split(":").filter(Boolean);
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const [surface, kind, ...rest] = parts;
|
||||||
|
if (kind === "group" || kind === "channel") {
|
||||||
|
const id = rest.join(":");
|
||||||
|
return { surface, kind, id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults {
|
function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults {
|
||||||
const resolved = resolveConfiguredModelRef({
|
const resolved = resolveConfiguredModelRef({
|
||||||
cfg,
|
cfg,
|
||||||
@ -913,9 +947,32 @@ function listSessionsFromStore(params: {
|
|||||||
const input = entry?.inputTokens ?? 0;
|
const input = entry?.inputTokens ?? 0;
|
||||||
const output = entry?.outputTokens ?? 0;
|
const output = entry?.outputTokens ?? 0;
|
||||||
const total = entry?.totalTokens ?? input + output;
|
const total = entry?.totalTokens ?? input + output;
|
||||||
|
const parsed = parseGroupKey(key);
|
||||||
|
const surface = entry?.surface ?? parsed?.surface;
|
||||||
|
const subject = entry?.subject;
|
||||||
|
const room = entry?.room;
|
||||||
|
const space = entry?.space;
|
||||||
|
const id = parsed?.id;
|
||||||
|
const displayName =
|
||||||
|
entry?.displayName ??
|
||||||
|
(surface
|
||||||
|
? buildGroupDisplayName({
|
||||||
|
surface,
|
||||||
|
subject,
|
||||||
|
room,
|
||||||
|
space,
|
||||||
|
id,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
: undefined);
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
kind: classifySessionKey(key),
|
kind: classifySessionKey(key, entry),
|
||||||
|
displayName,
|
||||||
|
surface,
|
||||||
|
subject,
|
||||||
|
room,
|
||||||
|
space,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
sessionId: entry?.sessionId,
|
sessionId: entry?.sessionId,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
@ -1265,6 +1322,31 @@ export async function startGatewayServer(
|
|||||||
port = 18789,
|
port = 18789,
|
||||||
opts: GatewayServerOptions = {},
|
opts: GatewayServerOptions = {},
|
||||||
): Promise<GatewayServer> {
|
): Promise<GatewayServer> {
|
||||||
|
const configSnapshot = await readConfigFileSnapshot();
|
||||||
|
if (configSnapshot.legacyIssues.length > 0) {
|
||||||
|
if (isNixMode) {
|
||||||
|
throw new Error(
|
||||||
|
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { config: migrated, changes } = migrateLegacyConfig(
|
||||||
|
configSnapshot.parsed,
|
||||||
|
);
|
||||||
|
if (!migrated) {
|
||||||
|
throw new Error(
|
||||||
|
"Legacy config entries detected but auto-migration failed. Run \"clawdis doctor\" to migrate.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await writeConfigFile(migrated);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
log.info(
|
||||||
|
`gateway: migrated legacy config entries:\n${changes
|
||||||
|
.map((entry) => `- ${entry}`)
|
||||||
|
.join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cfgAtStart = loadConfig();
|
const cfgAtStart = loadConfig();
|
||||||
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
||||||
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
||||||
@ -1288,7 +1370,8 @@ export async function startGatewayServer(
|
|||||||
...tailscaleOverrides,
|
...tailscaleOverrides,
|
||||||
};
|
};
|
||||||
const tailscaleMode = tailscaleConfig.mode ?? "off";
|
const tailscaleMode = tailscaleConfig.mode ?? "off";
|
||||||
const token = getGatewayToken();
|
const token =
|
||||||
|
authConfig.token ?? process.env.CLAWDIS_GATEWAY_TOKEN ?? undefined;
|
||||||
const password =
|
const password =
|
||||||
authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined;
|
authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined;
|
||||||
const authMode: ResolvedGatewayAuth["mode"] =
|
const authMode: ResolvedGatewayAuth["mode"] =
|
||||||
@ -2017,6 +2100,15 @@ export async function startGatewayServer(
|
|||||||
const startTelegramProvider = async () => {
|
const startTelegramProvider = async () => {
|
||||||
if (telegramTask) return;
|
if (telegramTask) return;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
if (!cfg.telegram) {
|
||||||
|
telegramRuntime = {
|
||||||
|
...telegramRuntime,
|
||||||
|
running: false,
|
||||||
|
lastError: "not configured",
|
||||||
|
};
|
||||||
|
logTelegram.info("skipping provider start (telegram not configured)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cfg.telegram?.enabled === false) {
|
if (cfg.telegram?.enabled === false) {
|
||||||
telegramRuntime = {
|
telegramRuntime = {
|
||||||
...telegramRuntime,
|
...telegramRuntime,
|
||||||
@ -2111,6 +2203,15 @@ export async function startGatewayServer(
|
|||||||
const startDiscordProvider = async () => {
|
const startDiscordProvider = async () => {
|
||||||
if (discordTask) return;
|
if (discordTask) return;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
if (!cfg.discord) {
|
||||||
|
discordRuntime = {
|
||||||
|
...discordRuntime,
|
||||||
|
running: false,
|
||||||
|
lastError: "not configured",
|
||||||
|
};
|
||||||
|
logDiscord.info("skipping provider start (discord not configured)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cfg.discord?.enabled === false) {
|
if (cfg.discord?.enabled === false) {
|
||||||
discordRuntime = {
|
discordRuntime = {
|
||||||
...discordRuntime,
|
...discordRuntime,
|
||||||
@ -2153,9 +2254,7 @@ export async function startGatewayServer(
|
|||||||
token: discordToken.trim(),
|
token: discordToken.trim(),
|
||||||
runtime: discordRuntimeEnv,
|
runtime: discordRuntimeEnv,
|
||||||
abortSignal: discordAbort.signal,
|
abortSignal: discordAbort.signal,
|
||||||
allowFrom: cfg.discord?.allowFrom,
|
slashCommand: cfg.discord?.slashCommand,
|
||||||
guildAllowFrom: cfg.discord?.guildAllowFrom,
|
|
||||||
requireMention: cfg.discord?.requireMention,
|
|
||||||
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
||||||
historyLimit: cfg.discord?.historyLimit,
|
historyLimit: cfg.discord?.historyLimit,
|
||||||
})
|
})
|
||||||
@ -2216,6 +2315,26 @@ export async function startGatewayServer(
|
|||||||
logSignal.info("skipping provider start (signal.enabled=false)");
|
logSignal.info("skipping provider start (signal.enabled=false)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const signalCfg = cfg.signal;
|
||||||
|
const signalMeaningfullyConfigured = Boolean(
|
||||||
|
signalCfg.account?.trim() ||
|
||||||
|
signalCfg.httpUrl?.trim() ||
|
||||||
|
signalCfg.cliPath?.trim() ||
|
||||||
|
signalCfg.httpHost?.trim() ||
|
||||||
|
typeof signalCfg.httpPort === "number" ||
|
||||||
|
typeof signalCfg.autoStart === "boolean",
|
||||||
|
);
|
||||||
|
if (!signalMeaningfullyConfigured) {
|
||||||
|
signalRuntime = {
|
||||||
|
...signalRuntime,
|
||||||
|
running: false,
|
||||||
|
lastError: "not configured",
|
||||||
|
};
|
||||||
|
logSignal.info(
|
||||||
|
"skipping provider start (signal config present but missing required fields)",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const host = cfg.signal?.httpHost?.trim() || "127.0.0.1";
|
const host = cfg.signal?.httpHost?.trim() || "127.0.0.1";
|
||||||
const port = cfg.signal?.httpPort ?? 8080;
|
const port = cfg.signal?.httpPort ?? 8080;
|
||||||
const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`;
|
const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`;
|
||||||
@ -2881,6 +3000,12 @@ export async function startGatewayServer(
|
|||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
model: entry?.model,
|
model: entry?.model,
|
||||||
contextTokens: entry?.contextTokens,
|
contextTokens: entry?.contextTokens,
|
||||||
|
displayName: entry?.displayName,
|
||||||
|
chatType: entry?.chatType,
|
||||||
|
surface: entry?.surface,
|
||||||
|
subject: entry?.subject,
|
||||||
|
room: entry?.room,
|
||||||
|
space: entry?.space,
|
||||||
lastChannel: entry?.lastChannel,
|
lastChannel: entry?.lastChannel,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
skillsSnapshot: entry?.skillsSnapshot,
|
skillsSnapshot: entry?.skillsSnapshot,
|
||||||
@ -4285,21 +4410,33 @@ export async function startGatewayServer(
|
|||||||
? Math.max(1000, timeoutMsRaw)
|
? Math.max(1000, timeoutMsRaw)
|
||||||
: 10_000;
|
: 10_000;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
const telegramCfg = cfg.telegram;
|
||||||
|
const telegramEnabled =
|
||||||
|
Boolean(telegramCfg) && telegramCfg?.enabled !== false;
|
||||||
const { token: telegramToken, source: tokenSource } =
|
const { token: telegramToken, source: tokenSource } =
|
||||||
resolveTelegramToken(cfg);
|
telegramEnabled
|
||||||
|
? resolveTelegramToken(cfg)
|
||||||
|
: { token: "", source: "none" as const };
|
||||||
let telegramProbe: TelegramProbe | undefined;
|
let telegramProbe: TelegramProbe | undefined;
|
||||||
let lastProbeAt: number | null = null;
|
let lastProbeAt: number | null = null;
|
||||||
if (probe && telegramToken) {
|
if (probe && telegramToken && telegramEnabled) {
|
||||||
telegramProbe = await probeTelegram(
|
telegramProbe = await probeTelegram(
|
||||||
telegramToken,
|
telegramToken,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
cfg.telegram?.proxy,
|
telegramCfg?.proxy,
|
||||||
);
|
);
|
||||||
lastProbeAt = Date.now();
|
lastProbeAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
const discordEnvToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
const discordCfg = cfg.discord;
|
||||||
const discordConfigToken = cfg.discord?.token?.trim();
|
const discordEnabled =
|
||||||
|
Boolean(discordCfg) && discordCfg?.enabled !== false;
|
||||||
|
const discordEnvToken = discordEnabled
|
||||||
|
? process.env.DISCORD_BOT_TOKEN?.trim()
|
||||||
|
: "";
|
||||||
|
const discordConfigToken = discordEnabled
|
||||||
|
? discordCfg?.token?.trim()
|
||||||
|
: "";
|
||||||
const discordToken = discordEnvToken || discordConfigToken || "";
|
const discordToken = discordEnvToken || discordConfigToken || "";
|
||||||
const discordTokenSource = discordEnvToken
|
const discordTokenSource = discordEnvToken
|
||||||
? "env"
|
? "env"
|
||||||
@ -4308,7 +4445,7 @@ export async function startGatewayServer(
|
|||||||
: "none";
|
: "none";
|
||||||
let discordProbe: DiscordProbe | undefined;
|
let discordProbe: DiscordProbe | undefined;
|
||||||
let discordLastProbeAt: number | null = null;
|
let discordLastProbeAt: number | null = null;
|
||||||
if (probe && discordToken) {
|
if (probe && discordToken && discordEnabled) {
|
||||||
discordProbe = await probeDiscord(discordToken, timeoutMs);
|
discordProbe = await probeDiscord(discordToken, timeoutMs);
|
||||||
discordLastProbeAt = Date.now();
|
discordLastProbeAt = Date.now();
|
||||||
}
|
}
|
||||||
@ -4320,7 +4457,17 @@ export async function startGatewayServer(
|
|||||||
const signalBaseUrl =
|
const signalBaseUrl =
|
||||||
signalCfg?.httpUrl?.trim() ||
|
signalCfg?.httpUrl?.trim() ||
|
||||||
`http://${signalHost}:${signalPort}`;
|
`http://${signalHost}:${signalPort}`;
|
||||||
const signalConfigured = Boolean(signalCfg) && signalEnabled;
|
const signalConfigured =
|
||||||
|
Boolean(signalCfg) &&
|
||||||
|
signalEnabled &&
|
||||||
|
Boolean(
|
||||||
|
signalCfg?.account?.trim() ||
|
||||||
|
signalCfg?.httpUrl?.trim() ||
|
||||||
|
signalCfg?.cliPath?.trim() ||
|
||||||
|
signalCfg?.httpHost?.trim() ||
|
||||||
|
typeof signalCfg?.httpPort === "number" ||
|
||||||
|
typeof signalCfg?.autoStart === "boolean",
|
||||||
|
);
|
||||||
let signalProbe: SignalProbe | undefined;
|
let signalProbe: SignalProbe | undefined;
|
||||||
let signalLastProbeAt: number | null = null;
|
let signalLastProbeAt: number | null = null;
|
||||||
if (probe && signalConfigured) {
|
if (probe && signalConfigured) {
|
||||||
@ -4362,7 +4509,7 @@ export async function startGatewayServer(
|
|||||||
lastError: whatsappRuntime.lastError ?? null,
|
lastError: whatsappRuntime.lastError ?? null,
|
||||||
},
|
},
|
||||||
telegram: {
|
telegram: {
|
||||||
configured: Boolean(telegramToken),
|
configured: telegramEnabled && Boolean(telegramToken),
|
||||||
tokenSource,
|
tokenSource,
|
||||||
running: telegramRuntime.running,
|
running: telegramRuntime.running,
|
||||||
mode: telegramRuntime.mode ?? null,
|
mode: telegramRuntime.mode ?? null,
|
||||||
@ -4373,7 +4520,7 @@ export async function startGatewayServer(
|
|||||||
lastProbeAt,
|
lastProbeAt,
|
||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
configured: Boolean(discordToken),
|
configured: discordEnabled && Boolean(discordToken),
|
||||||
tokenSource: discordTokenSource,
|
tokenSource: discordTokenSource,
|
||||||
running: discordRuntime.running,
|
running: discordRuntime.running,
|
||||||
lastStartAt: discordRuntime.lastStartAt ?? null,
|
lastStartAt: discordRuntime.lastStartAt ?? null,
|
||||||
@ -6521,7 +6668,7 @@ export async function startGatewayServer(
|
|||||||
if (explicit) return resolvedTo;
|
if (explicit) return resolvedTo;
|
||||||
|
|
||||||
const cfg = cfgForAgent ?? loadConfig();
|
const cfg = cfgForAgent ?? loadConfig();
|
||||||
const rawAllow = cfg.routing?.allowFrom ?? [];
|
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
|
||||||
if (rawAllow.includes("*")) return resolvedTo;
|
if (rawAllow.includes("*")) return resolvedTo;
|
||||||
const allowFrom = rawAllow
|
const allowFrom = rawAllow
|
||||||
.map((val) => normalizeE164(val))
|
.map((val) => normalizeE164(val))
|
||||||
|
|||||||
@ -168,8 +168,9 @@ export class IMessageRpcClient {
|
|||||||
let parsed: IMessageRpcResponse<unknown>;
|
let parsed: IMessageRpcResponse<unknown>;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(line) as IMessageRpcResponse<unknown>;
|
parsed = JSON.parse(line) as IMessageRpcResponse<unknown>;
|
||||||
} catch (_err) {
|
} catch (err) {
|
||||||
this.runtime?.error?.(`imsg rpc: failed to parse ${line}`);
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
this.runtime?.error?.(`imsg rpc: failed to parse ${line}: ${detail}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -61,8 +61,7 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
|
|||||||
|
|
||||||
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
|
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const raw =
|
const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? [];
|
||||||
opts.allowFrom ?? cfg.imessage?.allowFrom ?? cfg.routing?.allowFrom ?? [];
|
|
||||||
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
return raw.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user