diff --git a/AGENTS.md b/AGENTS.md index 26b42fd9a..1da355f35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ - **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. - 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. - 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5385b7c70..24940c720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,16 +11,25 @@ - `skillsInstall.*` → `skills.install.*` - 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) +- Sessions: group keys now use `surface:group:` / `surface:channel:`; 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 - 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). - 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`). +- 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. - 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. - 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. - 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. @@ -36,11 +45,19 @@ ### Fixes - 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. - 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. - 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 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: 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. @@ -49,8 +66,11 @@ - 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: 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). - 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 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 @@ -59,6 +79,7 @@ - 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. - 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/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. @@ -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. - 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: 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. - 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. diff --git a/README.md b/README.md index a8cb7900b..9f0dfd62d 100644 --- a/README.md +++ b/README.md @@ -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. -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**. @@ -157,7 +157,7 @@ Minimal `~/.clawdis/clawdis.json`: ```json5 { - routing: { + whatsapp: { allowFrom: ["+1234567890"] } } @@ -166,7 +166,7 @@ Minimal `~/.clawdis/clawdis.json`: ### WhatsApp - 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 @@ -184,7 +184,7 @@ Minimal `~/.clawdis/clawdis.json`: ### Discord - 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 { diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt index d37e9ff46..1e7a23dcf 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt @@ -469,7 +469,8 @@ class ChatController( val key = obj["key"].asStringOrNull()?.trim().orEmpty() if (key.isEmpty()) return@mapNotNull null val updatedAt = obj["updatedAt"].asLongOrNull() - ChatSessionEntry(key = key, updatedAtMs = updatedAt) + val displayName = obj["displayName"].asStringOrNull()?.trim() + ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName) } } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt index 2d27a5be4..8c7f1b3b3 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt @@ -25,6 +25,7 @@ data class ChatPendingToolCall( data class ChatSessionEntry( val key: String, val updatedAtMs: Long?, + val displayName: String? = null, ) data class ChatHistory( diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt index af958d71c..8ac6f0aca 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt @@ -62,6 +62,8 @@ fun ChatComposer( var showSessionMenu by remember { mutableStateOf(false) } val sessionOptions = resolveSessionChoices(sessionKey, sessions) + val currentSessionLabel = + sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk @@ -82,13 +84,13 @@ fun ChatComposer( onClick = { showSessionMenu = true }, contentPadding = ButtonDefaults.ContentPadding, ) { - Text("Session: $sessionKey") + Text("Session: $currentSessionLabel") } DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) { for (entry in sessionOptions) { DropdownMenuItem( - text = { Text(entry.key) }, + text = { Text(entry.displayName ?: entry.key) }, onClick = { onSelectSession(entry.key) showSessionMenu = false diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt index 55d30b9b7..218cb25f6 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt @@ -82,7 +82,7 @@ private fun SessionRow( verticalAlignment = Alignment.CenterVertically, 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)) if (isCurrent) { Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -90,4 +90,3 @@ private fun SessionRow( } } } - diff --git a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift index c54c4a067..41005e826 100644 --- a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift +++ b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift @@ -79,7 +79,7 @@ struct ContextMenuCardView: View { height: self.barHeight) HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(row.key) + Text(row.label) .font(.caption.weight(row.key == "main" ? .semibold : .regular)) .lineLimit(1) .truncationMode(.middle) diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 87eb6847a..8a8c31e8e 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -63,9 +63,11 @@ final class ControlChannel { self.logger.info("control channel state -> connecting") case .disconnected: self.logger.info("control channel state -> disconnected") + self.scheduleRecovery(reason: "disconnected") case let .degraded(message): let detail = message.isEmpty ? "degraded" : "degraded: \(message)" 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 var eventTask: Task? + private var recoveryTask: Task? + private var lastRecoveryAt: Date? private init() { self.startEventStream() @@ -231,7 +235,43 @@ final class ControlChannel { } 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 { diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift index a5ae9cbe4..117cde4e7 100644 --- a/apps/macos/Sources/Clawdis/HealthStore.swift +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -114,6 +114,7 @@ final class HealthStore { guard !self.isRefreshing else { return } self.isRefreshing = true defer { self.isRefreshing = false } + let previousError = self.lastError do { let data = try await ControlChannel.shared.health(timeout: 15) @@ -121,13 +122,23 @@ final class HealthStore { self.snapshot = decoded self.lastSuccess = Date() self.lastError = nil + if previousError != nil { + Self.logger.info("health refresh recovered") + } } else { self.lastError = "health output not JSON" if onDemand { self.snapshot = nil } + if previousError != self.lastError { + Self.logger.warning("health refresh failed: output not JSON") + } } } catch { - self.lastError = error.localizedDescription + let desc = error.localizedDescription + self.lastError = desc if onDemand { self.snapshot = nil } + if previousError != desc { + Self.logger.error("health refresh failed \(desc, privacy: .public)") + } } } diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 72b0abcfa..7a9a78f5c 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -374,6 +374,10 @@ struct MenuContent: View { Text(label) .font(.caption) .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) } .padding(.top, 2) } diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index f9509aae9..312744219 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { guard let insertIndex = self.findInsertIndex(in: menu) else { return } let width = self.initialWidth(for: menu) - guard self.isControlChannelConnected else { - menu.insertItem(self.makeMessageItem( - text: self.controlChannelStatusText, - symbolName: "wifi.slash", - width: width), at: insertIndex) - return - } + guard self.isControlChannelConnected else { return } guard let snapshot = self.cachedSnapshot else { let headerItem = NSMenuItem() @@ -195,17 +189,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { menu.insertItem(topSeparator, at: cursor) cursor += 1 - guard self.isControlChannelConnected else { - menu.insertItem( - self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width), - at: cursor) + if let gatewayEntry = self.gatewayEntry() { + let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width) + menu.insertItem(gatewayItem, at: cursor) 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 { menu.insertItem( self.makeMessageItem( @@ -229,15 +220,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { cursor += 1 } else { for entry in entries.prefix(8) { - let item = NSMenuItem() - 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) + let item = self.makeNodeItem(entry: entry, width: width) menu.insertItem(item, at: cursor) cursor += 1 } @@ -265,27 +248,56 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return false } - private var controlChannelStatusText: String { - switch ControlChannel.shared.state { - case .connected: - return "Connected" - case .connecting: - return "Connecting to gateway…" - case let .degraded(reason): - if self.shouldShowConnecting { return "Connecting to gateway…" } - return reason.nonEmpty ?? "No connection to gateway" - case .disconnected: - return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway" + private func gatewayEntry() -> NodeInfo? { + let mode = AppStateStore.shared.connectionMode + let isConnected = self.isControlChannelConnected + let port = GatewayEnvironment.gatewayPort() + var host: String? + var platform: String? + + switch mode { + case .remote: + platform = "remote" + 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 { - switch GatewayProcessManager.shared.status { - case .starting, .running, .attachedExisting: - return true - case .stopped, .failed: - return false - } + private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem { + let item = NSMenuItem() + 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) + return item } private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem { @@ -293,8 +305,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { Label(text, systemImage: symbolName) .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) .padding(.leading, 18) .padding(.trailing, 12) .padding(.vertical, 6) diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index 882b7ec3e..70635929b 100644 --- a/apps/macos/Sources/Clawdis/NodesMenu.swift +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -2,15 +2,30 @@ import AppKit import SwiftUI struct NodeMenuEntryFormatter { + static func isGateway(_ entry: NodeInfo) -> Bool { + entry.nodeId == "gateway" + } + static func isConnected(_ entry: NodeInfo) -> Bool { entry.isConnected } 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 { + 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) var prefix = "Node: \(name)" if let ip = entry.remoteIp?.nonEmpty { @@ -112,6 +127,11 @@ struct NodeMenuEntryFormatter { } 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 family.contains("mac") { return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") diff --git a/apps/macos/Sources/Clawdis/NodesStore.swift b/apps/macos/Sources/Clawdis/NodesStore.swift index 2c00e15f7..cc647e102 100644 --- a/apps/macos/Sources/Clawdis/NodesStore.swift +++ b/apps/macos/Sources/Clawdis/NodesStore.swift @@ -75,10 +75,26 @@ final class NodesStore { self.lastError = nil self.statusMessage = nil } 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.nodes = [] self.lastError = error.localizedDescription 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 + } } diff --git a/apps/macos/Sources/Clawdis/PermissionManager.swift b/apps/macos/Sources/Clawdis/PermissionManager.swift index e6de4e58f..8afb9745f 100644 --- a/apps/macos/Sources/Clawdis/PermissionManager.swift +++ b/apps/macos/Sources/Clawdis/PermissionManager.swift @@ -31,6 +31,8 @@ enum PermissionManager { await self.ensureMicrophone(interactive: interactive) case .speechRecognition: await self.ensureSpeechRecognition(interactive: interactive) + case .camera: + await self.ensureCamera(interactive: interactive) } } @@ -114,6 +116,24 @@ enum PermissionManager { 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 { let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized let speech = SFSpeechRecognizer.authorizationStatus() == .authorized @@ -153,6 +173,9 @@ enum PermissionManager { case .speechRecognition: results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized + + case .camera: + results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized } } 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 { private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission") diff --git a/apps/macos/Sources/Clawdis/PermissionsSettings.swift b/apps/macos/Sources/Clawdis/PermissionsSettings.swift index fd2040f22..955e17edb 100644 --- a/apps/macos/Sources/Clawdis/PermissionsSettings.swift +++ b/apps/macos/Sources/Clawdis/PermissionsSettings.swift @@ -120,6 +120,7 @@ struct PermissionRow: View { case .screenRecording: "Screen Recording" case .microphone: "Microphone" case .speechRecognition: "Speech Recognition" + case .camera: "Camera" } } @@ -132,6 +133,7 @@ struct PermissionRow: View { case .screenRecording: "Capture the screen for context or screenshots" case .microphone: "Allow Voice Wake and audio capture" 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 .microphone: "mic" case .speechRecognition: "waveform" + case .camera: "camera" } } } diff --git a/apps/macos/Sources/Clawdis/SessionData.swift b/apps/macos/Sources/Clawdis/SessionData.swift index ebfbaac7f..ce0501209 100644 --- a/apps/macos/Sources/Clawdis/SessionData.swift +++ b/apps/macos/Sources/Clawdis/SessionData.swift @@ -8,6 +8,11 @@ struct GatewaySessionDefaultsRecord: Codable { struct GatewaySessionEntryRecord: Codable { let key: String + let displayName: String? + let surface: String? + let subject: String? + let room: String? + let space: String? let updatedAt: Double? let sessionId: String? let systemSent: Bool? @@ -65,6 +70,11 @@ struct SessionRow: Identifiable { let id: String let key: String let kind: SessionKind + let displayName: String? + let surface: String? + let subject: String? + let room: String? + let space: String? let updatedAt: Date? let sessionId: String? let thinkingLevel: String? @@ -75,6 +85,7 @@ struct SessionRow: Identifiable { let model: String? var ageText: String { relativeAge(from: self.updatedAt) } + var label: String { self.displayName ?? self.key } var flagLabels: [String] { var flags: [String] = [] @@ -92,6 +103,8 @@ enum SessionKind { static func from(key: String) -> SessionKind { if key == "global" { return .global } if key.hasPrefix("group:") { return .group } + if key.contains(":group:") { return .group } + if key.contains(":channel:") { return .group } if key == "unknown" { return .unknown } return .direct } @@ -127,6 +140,11 @@ extension SessionRow { id: "direct-1", key: "user@example.com", kind: .direct, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date().addingTimeInterval(-90), sessionId: "sess-direct-1234", thinkingLevel: "low", @@ -137,8 +155,13 @@ extension SessionRow { model: "claude-3.5-sonnet"), SessionRow( id: "group-1", - key: "group:engineering", + key: "discord:channel:release-squad", kind: .group, + displayName: "discord:#release-squad", + surface: "discord", + subject: nil, + room: "#release-squad", + space: nil, updatedAt: Date().addingTimeInterval(-3600), sessionId: "sess-group-4321", thinkingLevel: "medium", @@ -151,6 +174,11 @@ extension SessionRow { id: "global", key: "global", kind: .global, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date().addingTimeInterval(-86400), sessionId: nil, thinkingLevel: nil, @@ -269,6 +297,11 @@ enum SessionLoader { id: entry.key, 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, sessionId: entry.sessionId, thinkingLevel: entry.thinkingLevel, diff --git a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift index 5cfd1b4a0..7d1ad5ca8 100644 --- a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift +++ b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift @@ -36,7 +36,7 @@ struct SessionMenuLabelView: View { height: self.barHeight) HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(self.row.key) + Text(self.row.label) .font(.caption.weight(self.row.key == "main" ? .semibold : .regular)) .foregroundStyle(self.primaryTextColor) .lineLimit(1) diff --git a/apps/macos/Sources/Clawdis/SessionsSettings.swift b/apps/macos/Sources/Clawdis/SessionsSettings.swift index 88e87eea6..4a2a0e81e 100644 --- a/apps/macos/Sources/Clawdis/SessionsSettings.swift +++ b/apps/macos/Sources/Clawdis/SessionsSettings.swift @@ -89,7 +89,7 @@ struct SessionsSettings: View { private func sessionRow(_ row: SessionRow) -> some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(row.key) + Text(row.label) .font(.subheadline.bold()) .lineLimit(1) .truncationMode(.middle) diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index 469d80405..0a7bea442 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -11,6 +11,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable { case screenRecording case microphone case speechRecognition + case camera } public enum CameraFacing: String, Codable, Sendable { diff --git a/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift index cfd08d03b..91048ec87 100644 --- a/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift @@ -29,6 +29,11 @@ struct MenuSessionsInjectorTests { id: "main", key: "main", kind: .direct, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date(), sessionId: "s1", thinkingLevel: "low", @@ -38,9 +43,14 @@ struct MenuSessionsInjectorTests { tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000), model: "claude-opus-4-5"), SessionRow( - id: "group:alpha", - key: "group:alpha", + id: "discord:group:alpha", + key: "discord:group:alpha", kind: .group, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date(timeIntervalSinceNow: -60), sessionId: "s2", thinkingLevel: "high", diff --git a/apps/macos/Tests/ClawdisIPCTests/SessionDataTests.swift b/apps/macos/Tests/ClawdisIPCTests/SessionDataTests.swift index 8d8e36a54..d386558a5 100644 --- a/apps/macos/Tests/ClawdisIPCTests/SessionDataTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/SessionDataTests.swift @@ -6,7 +6,7 @@ import Testing struct SessionDataTests { @Test func sessionKindFromKeyDetectsCommonKinds() { #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: "user@example.com") == .direct) } @@ -27,6 +27,11 @@ struct SessionDataTests { id: "x", key: "user@example.com", kind: .direct, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date(), sessionId: nil, thinkingLevel: "high", @@ -41,4 +46,3 @@ struct SessionDataTests { #expect(row.flagLabels.contains("aborted")) } } - diff --git a/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift index a0d3a60d5..a50c616b2 100644 --- a/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift @@ -8,9 +8,9 @@ struct WorkActivityStoreTests { @Test func mainSessionJobPreemptsOther() { 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.current?.sessionKey == "group:1") + #expect(store.current?.sessionKey == "discord:group:1") store.handleJob(sessionKey: "main", state: "started") #expect(store.iconState == .workingMain(.job)) @@ -18,9 +18,9 @@ struct WorkActivityStoreTests { store.handleJob(sessionKey: "main", state: "finished") #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.current == nil) } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift index 2ea691c43..9bdd2bc93 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift @@ -103,7 +103,7 @@ struct ClawdisChatComposer: View { set: { next in self.viewModel.switchSession(to: next) })) { ForEach(self.viewModel.sessionChoices, id: \.key) { session in - Text(session.key) + Text(session.displayName ?? session.key) .font(.system(.caption, design: .monospaced)) .tag(session.key) } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift index 475528275..1db032523 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift @@ -10,6 +10,11 @@ public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable public let key: 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 sessionId: String? diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift index f413c3d3e..a4a8da6cc 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift @@ -14,7 +14,7 @@ struct ChatSessionsSheet: View { self.dismiss() } label: { VStack(alignment: .leading, spacing: 4) { - Text(session.key) + Text(session.displayName ?? session.key) .font(.system(.body, design: .monospaced)) .lineLimit(1) if let updatedAt = session.updatedAt, updatedAt > 0 { diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index 4beb1602c..4bb862486 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -23,7 +23,7 @@ public struct ClawdisChatView: View { static let composerPaddingHorizontal: CGFloat = 0 static let stackSpacing: CGFloat = 0 static let messageSpacing: CGFloat = 6 - static let messageListPaddingTop: CGFloat = 0 + static let messageListPaddingTop: CGFloat = 12 static let messageListPaddingBottom: CGFloat = 16 static let messageListPaddingHorizontal: CGFloat = 6 #else @@ -32,7 +32,7 @@ public struct ClawdisChatView: View { static let composerPaddingHorizontal: CGFloat = 6 static let stackSpacing: CGFloat = 6 static let messageSpacing: CGFloat = 12 - static let messageListPaddingTop: CGFloat = 4 + static let messageListPaddingTop: CGFloat = 10 static let messageListPaddingBottom: CGFloat = 6 static let messageListPaddingHorizontal: CGFloat = 8 #endif diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift index 025e91ca6..010f56046 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift @@ -341,6 +341,11 @@ public final class ClawdisChatViewModel { ClawdisChatSessionEntry( key: key, kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: nil, sessionId: nil, systemSent: nil, diff --git a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift index acdcd7a6c..9b2a7cfa6 100644 --- a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift +++ b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift @@ -282,6 +282,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "recent-1", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: recent, sessionId: nil, systemSent: nil, @@ -296,6 +301,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "main", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: stale, sessionId: nil, systemSent: nil, @@ -310,6 +320,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "recent-2", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: recentOlder, sessionId: nil, systemSent: nil, @@ -324,6 +339,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "old-1", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: stale, sessionId: nil, systemSent: nil, @@ -365,6 +385,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "main", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: recent, sessionId: nil, systemSent: nil, diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index 4c967fd16..ddf2d0ea1 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -83,7 +83,7 @@ git commit -m "Add Clawd workspace" ## 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. - 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:`; heartbeats keep background tasks alive. +- Direct chats collapse into the shared `main` session by default; groups stay isolated as `surface:group:` (rooms: `surface:channel:`); heartbeats keep background tasks alive. ## Core Skills (enable in Settings → Skills) - **mcporter** — Tool server runtime/CLI for managing external skill backends. diff --git a/docs/agent.md b/docs/agent.md index 60ce61c3f..01cb830c2 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -76,7 +76,7 @@ Incoming user messages are queued while the agent is streaming. The queue is che At minimum, set: - `agent.workspace` -- `routing.allowFrom` (strongly recommended) +- `whatsapp.allowFrom` (strongly recommended) --- diff --git a/docs/clawd.md b/docs/clawd.md index 4d87403bf..1c43847d0 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -17,7 +17,7 @@ You’re putting an agent in a position to: - send messages back out via WhatsApp/Telegram/Discord 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. - 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 { - routing: { + whatsapp: { allowFrom: ["+15555550123"] } } @@ -124,8 +124,10 @@ Example: // Start with 0; enable later. heartbeat: { every: "0m" } }, + whatsapp: { + allowFrom: ["+15555550123"] + }, routing: { - allowFrom: ["+15555550123"], groupChat: { requireMention: true, mentionPatterns: ["@clawd", "clawd"] diff --git a/docs/configuration.md b/docs/configuration.md index 421bf6a04..7d7f30964 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,7 @@ read_when: 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: -- 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`) - customize message prefixes (`messages`) - 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 { 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 { - 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) -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. ```json5 @@ -173,20 +173,38 @@ Configure the Discord bot by setting the bot token and optional gating: discord: { enabled: true, 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 - 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:` (DM) or `channel:` (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:` (DM) or `channel:` (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) @@ -537,7 +555,7 @@ Defaults: mode: "local", // or "remote" bind: "loopback", // 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" } } } @@ -548,6 +566,7 @@ Notes: Auth and Tailscale: - `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). - `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended). - `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth. diff --git a/docs/discord.md b/docs/discord.md index 475525aa6..ad6bf7126 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -11,22 +11,27 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa ## Goals - 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:`. +- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `discord:group:` (display names use `discord:#`). +- 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. ## How it works 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. 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:` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. -6. Guild channels: use `channel:` for delivery. Mentions are required by default; disable with `discord.requireMention = false`. -7. Optional DM allowlist: reuse `discord.allowFrom` with user ids (`1234567890` or `discord:1234567890`). Use `"*"` to allow all DMs. -8. Optional guild allowlist: set `discord.guildAllowFrom` with `guilds` and/or `users` to gate who can invoke the bot in servers. -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. -10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool. +6. Guild channels: use `channel:` for delivery. Mentions are required by default and can be set per guild or per channel. +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 rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. +9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists. +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: 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 - 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: { enabled: true, token: "abc.123", - allowFrom: ["123456789012345678"], - guildAllowFrom: { - guilds: ["123456789012345678"], - users: ["987654321098765432"] - }, - requireMention: true, 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. -- `guildAllowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids). When both are set, both must match. -- `requireMention`: when `true`, messages in guild channels must mention the bot. +- `dm.enabled`: set `false` to ignore all DMs (default `true`). +- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender. +- `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..slug`: optional friendly slug used for display names. +- `guilds..users`: optional per-guild user allowlist (ids or names). +- `guilds..channels`: channel rules (keys are channel slugs or ids). +- `guilds..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. - `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`). +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 When `discord.enableReactions = true`, the agent can call `clawdis_discord` with: - `action: "react"` diff --git a/docs/doctor.md b/docs/doctor.md new file mode 100644 index 000000000..bd81cd1fe --- /dev/null +++ b/docs/doctor.md @@ -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 +``` diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 000000000..815c27198 --- /dev/null +++ b/docs/faq.md @@ -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 ` | 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).* 🦞 diff --git a/docs/grammy.md b/docs/grammy.md index 7214f40e7..cfd593622 100644 --- a/docs/grammy.md +++ b/docs/grammy.md @@ -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`. - **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). -- **Sessions:** direct chats map to `main`; groups map to `group:`; replies route back to the same surface. +- **Sessions:** direct chats map to `main`; groups map to `telegram:group:`; replies route back to the same surface. - **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. diff --git a/docs/group-messages.md b/docs/group-messages.md index c50a5a5d0..e8fb355d0 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -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) - 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. -- Per-group sessions: session keys look like `group:` 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. +- 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 `whatsapp:group:` 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]`. - 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. @@ -45,7 +45,7 @@ Use the group chat command: - `/activation mention` - `/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 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 - 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. -- Session store entries will appear as `group:` 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:` in the session store (`~/.clawdis/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet. diff --git a/docs/groups.md b/docs/groups.md index bc5aa96eb..b24b27e39 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -8,10 +8,14 @@ read_when: Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage. ## Session keys -- Group sessions use `group:` in `ctx.From`. +- Group sessions use `surface:group:` session keys (rooms/channels use `surface:channel:`). - Direct chats use the main session (or per-sender if configured). - Heartbeats are skipped for group sessions. +## Display labels +- UI labels use `displayName` when available, formatted as `surface:`. +- `#room` is reserved for rooms/channels; group chats use `g-` (lowercase, spaces -> `-`, keep `#@+._-`). + ## Mention gating (default) Group messages require a mention unless overridden per group. @@ -36,7 +40,7 @@ Group owners can toggle per-group activation: - `/activation mention` - `/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 Group inbound payloads set: diff --git a/docs/health.md b/docs/health.md index 5d2ec90dd..316ac6fab 100644 --- a/docs/health.md +++ b/docs/health.md @@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## When something fails - `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). -- 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 `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 ` to override the 10s default. diff --git a/docs/index.md b/docs/index.md index 160725093..dd9316fb5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -100,22 +100,21 @@ clawdis send --to +15555550123 --message "Hello from CLAWDIS" 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 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: ```json5 { - routing: { - allowFrom: ["+15555550123"], - groupChat: { requireMention: true, mentionPatterns: ["@clawd"] } - } + whatsapp: { allowFrom: ["+15555550123"] }, + routing: { groupChat: { requireMention: true, mentionPatterns: ["@clawd"] } } } ``` ## Docs - Start here: + - [FAQ](./faq.md) ← *common questions answered* - [Configuration](./configuration.md) - [Nix mode](./nix.md) - [Clawd personal assistant setup](./clawd.md) diff --git a/docs/onboarding.md b/docs/onboarding.md index ee11f6dcf..f47d786e3 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.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. - **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). ## 2) Local-only: Connect Claude (Anthropic OAuth) diff --git a/docs/security.md b/docs/security.md index c5c261bbb..32d76adae 100644 --- a/docs/security.md +++ b/docs/security.md @@ -42,7 +42,7 @@ This is social engineering 101. Create distrust, encourage snooping. ```json { - "routing": { + "whatsapp": { "allowFrom": ["+15555550123"] } } diff --git a/docs/session.md b/docs/session.md index 756bd5181..8430819fa 100644 --- a/docs/session.md +++ b/docs/session.md @@ -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`). - Transcripts: `~/.clawdis/sessions/.jsonl` (one file per session id). - 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. ## Mapping transports → session keys - 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. -- Group chats still isolate state with `group:` keys; do not reuse the primary key for groups. +- Group chats isolate state with `surface:group:` keys (rooms/channels use `surface:channel:`); do not reuse the primary key for groups. (Discord display names show `discord:#`.) + - Legacy `group::` and `group:` keys are still recognized. ## Lifecyle - Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message. diff --git a/docs/sessions.md b/docs/sessions.md new file mode 100644 index 000000000..56627b95a --- /dev/null +++ b/docs/sessions.md @@ -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`. diff --git a/docs/signal.md b/docs/signal.md index cc6898dbe..9c389bf99 100644 --- a/docs/signal.md +++ b/docs/signal.md @@ -30,6 +30,11 @@ You can still run Clawdis on your own Signal account if your goal is “respond ## Quickstart (bot number) 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: - Run: `signal-cli link -n "Clawdis"` - 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`. 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) - `POST /api/v1/rpc` JSON-RPC request (single or batch). - `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. - 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 - `send` (recipient/groupId/username, message, attachments). - `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) - Direct: `signal:+15551234567` (or plain `+15551234567`) -- Groups: `group:` +- Groups: `signal:group:` - Usernames: `username:` / `u:` ## Process plan (Clawdis adapter) diff --git a/docs/surface.md b/docs/surface.md index 9135e80bc..e0cc05d9c 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -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. - **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:`, 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:` (rooms: `surface:channel:`), so they remain isolated. - **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdis/sessions/.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. - **Implementation hints:** diff --git a/docs/telegram.md b/docs/telegram.md index a30c135c4..797783f72 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -11,20 +11,21 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup ## Goals - 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:`. +- Share the same `main` session used by WhatsApp/WebChat; groups stay isolated as `telegram:group:`. - Keep transport routing deterministic: replies always go back to the surface they arrived on. ## How it will work (Bot API) 1) Create a bot with @BotFather and grab the token. 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. - **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. - 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). -5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `group:` and require mention/command to trigger replies. -6) Optional allowlist: reuse `routing.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). +5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:` and require mention/command to trigger replies. +6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). ## Capabilities & limits (Bot API) - Sees only messages sent after it’s added to a chat; no pre-history access. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 058d6e726..6588c21c9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -22,9 +22,9 @@ The agent was interrupted mid-response. ### Messages Not Triggering -**Check 1:** Is the sender in `routing.allowFrom`? +**Check 1:** Is the sender in `whatsapp.allowFrom`? ```bash -cat ~/.clawdis/clawdis.json | jq '.routing.allowFrom' +cat ~/.clawdis/clawdis.json | jq '.whatsapp.allowFrom' ``` **Check 2:** For group chats, is mention required? diff --git a/docs/whatsapp.md b/docs/whatsapp.md index aa4b6a5b3..7f854551f 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -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. - Status/broadcast chats are ignored. - Direct chats use E.164; groups use group JID. -- **Allowlist**: `routing.allowFrom` enforced for direct chats only. - - If `routing.allowFrom` is empty, default allowlist = self number (self-chat mode). +- **Allowlist**: `whatsapp.allowFrom` enforced for direct chats only. + - If `whatsapp.allowFrom` is empty, default allowlist = self number (self-chat mode). - **Self-chat mode**: avoids auto read receipts and ignores mention JIDs. - Read receipts sent for non-self-chat DMs. @@ -52,12 +52,12 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. - `` ## Groups -- Groups map to `group:` sessions. +- Groups map to `whatsapp:group:` sessions. - Activation modes: - `mention` (default): requires @mention or regex match. - `always`: always triggers. - `/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**: - Recent messages (default 50) inserted under: `[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. ## Config quick map -- `routing.allowFrom` (DM allowlist). +- `whatsapp.allowFrom` (DM allowlist). - `routing.groupChat.mentionPatterns` - `routing.groupChat.historyLimit` - `messages.messagePrefix` (inbound prefix) diff --git a/docs/wizard.md b/docs/wizard.md index 3e319fd6d..2b84c6ef7 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -58,6 +58,7 @@ It does **not** install or change anything on the remote host. 4) **Gateway** - 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. 5) **Providers** diff --git a/skills/local-places/SERVER_README.md b/skills/local-places/SERVER_README.md new file mode 100644 index 000000000..1a69931f2 --- /dev/null +++ b/skills/local-places/SERVER_README.md @@ -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 +``` diff --git a/skills/local-places/SKILL.md b/skills/local-places/SKILL.md new file mode 100644 index 000000000..bc563d419 --- /dev/null +++ b/skills/local-places/SKILL.md @@ -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. diff --git a/skills/local-places/pyproject.toml b/skills/local-places/pyproject.toml new file mode 100644 index 000000000..c59e336a1 --- /dev/null +++ b/skills/local-places/pyproject.toml @@ -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"] diff --git a/skills/local-places/src/local_places/__init__.py b/skills/local-places/src/local_places/__init__.py new file mode 100644 index 000000000..07c5de9e2 --- /dev/null +++ b/skills/local-places/src/local_places/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 000000000..0a17848a4 Binary files /dev/null and b/skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc differ diff --git a/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc new file mode 100644 index 000000000..94944facf Binary files /dev/null and b/skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc differ diff --git a/skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc new file mode 100644 index 000000000..ec25ea963 Binary files /dev/null and b/skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc differ diff --git a/skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc b/skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc new file mode 100644 index 000000000..997365113 Binary files /dev/null and b/skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc differ diff --git a/skills/local-places/src/local_places/google_places.py b/skills/local-places/src/local_places/google_places.py new file mode 100644 index 000000000..5a9bd60a3 --- /dev/null +++ b/skills/local-places/src/local_places/google_places.py @@ -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) diff --git a/skills/local-places/src/local_places/main.py b/skills/local-places/src/local_places/main.py new file mode 100644 index 000000000..1197719de --- /dev/null +++ b/skills/local-places/src/local_places/main.py @@ -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) diff --git a/skills/local-places/src/local_places/schemas.py b/skills/local-places/src/local_places/schemas.py new file mode 100644 index 000000000..e0590e659 --- /dev/null +++ b/skills/local-places/src/local_places/schemas.py @@ -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 diff --git a/skills/songsee/SKILL.md b/skills/songsee/SKILL.md new file mode 100644 index 000000000..8141113c2 --- /dev/null +++ b/skills/songsee/SKILL.md @@ -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. diff --git a/skills/trello/SKILL.md b/skills/trello/SKILL.md new file mode 100644 index 000000000..4ddd421d2 --- /dev/null +++ b/skills/trello/SKILL.md @@ -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}' +``` diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 18d896dc0..9b4502021 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -10,7 +10,7 @@ describe("buildAgentSystemPromptAppend", () => { expect(prompt).toContain("## User Identity"); 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.", ); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 3eed242a5..796cb763b 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -25,7 +25,7 @@ export function buildAgentSystemPromptAppend(params: { .filter(Boolean); const ownerLine = 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; const reasoningHint = params.reasoningTagHint ? [ @@ -36,7 +36,7 @@ export function buildAgentSystemPromptAppend(params: { "Only text inside is shown to the user; everything else is discarded and never seen by the user.", "Example:", "Short internal reasoning.", - "Hey Peter! What would you like to do next?", + "Hey there! What would you like to do next?", ].join(" ") : undefined; const runtimeInfo = params.runtimeInfo; diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 98f65c91a..d5c339530 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -118,7 +118,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: path.join(home, "sessions.json") }, @@ -168,7 +168,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }, ); @@ -195,7 +195,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }, ); @@ -208,7 +208,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }, ); @@ -264,7 +264,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: storePath }, @@ -325,7 +325,7 @@ describe("directive parsing", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: storePath }, @@ -506,7 +506,7 @@ describe("directive parsing", () => { workspace: path.join(home, "clawd"), allowedModels: ["openai/gpt-4.1-mini"], }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: storePath }, diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 62c77995b..8b8242208 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -42,7 +42,7 @@ function makeCfg(home: string) { model: "anthropic/claude-opus-4-5", workspace: join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: join(home, "sessions.json") }, @@ -220,6 +220,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", + Surface: "whatsapp", SenderE164: "+2000", }, {}, @@ -230,7 +231,7 @@ describe("trigger handling", () => { const store = JSON.parse( await fs.readFile(cfg.session.store, "utf-8"), ) as Record; - expect(store["group:123@g.us"]?.groupActivation).toBe("always"); + expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -244,6 +245,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", + Surface: "whatsapp", SenderE164: "+999", }, {}, @@ -270,6 +272,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", + Surface: "whatsapp", SenderE164: "+2000", GroupSubject: "Test Group", GroupMembers: "Alice (+1), Bob (+2)", @@ -280,8 +283,10 @@ describe("trigger handling", () => { model: "anthropic/claude-opus-4-5", workspace: join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], + }, + routing: { groupChat: { requireMention: false }, }, session: { store: join(home, "sessions.json") }, @@ -321,7 +326,7 @@ describe("trigger handling", () => { model: "anthropic/claude-opus-4-5", workspace: join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { @@ -360,7 +365,7 @@ describe("trigger handling", () => { model: "anthropic/claude-opus-4-5", workspace: join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 916c16d4a..b3c380a38 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -27,9 +27,11 @@ import { } from "../agents/workspace.js"; import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { + buildGroupDisplayName, DEFAULT_IDLE_MINUTES, DEFAULT_RESET_TRIGGERS, loadSessionStore, + resolveGroupSessionKey, resolveSessionKey, resolveSessionTranscriptPath, resolveStorePath, @@ -364,9 +366,9 @@ export async function getReplyFromConfig( let persistedModelOverride: string | undefined; let persistedProviderOverride: string | undefined; + const groupResolution = resolveGroupSessionKey(ctx); const isGroup = - typeof ctx.From === "string" && - (ctx.From.includes("@g.us") || ctx.From.startsWith("group:")); + ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution); const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "") .trim() .toLowerCase(); @@ -399,6 +401,13 @@ export async function getReplyFromConfig( sessionKey = resolveSessionKey(sessionScope, ctx, mainKey); 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 idleMs = idleMinutes * 60_000; const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; @@ -431,7 +440,41 @@ export async function getReplyFromConfig( modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, 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; await saveSessionStore(storePath, sessionStore); @@ -798,14 +841,20 @@ export async function getReplyFromConfig( const perMessageQueueMode = hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined; - // Optional allowlist by origin number (E.164 without whatsapp: prefix) - const configuredAllowFrom = cfg.routing?.allowFrom; + const surface = (ctx.Surface ?? "").trim().toLowerCase(); + 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 to = (ctx.To ?? "").replace(/^whatsapp:/, ""); - const isSamePhone = from && to && from === to; - // If no config is present, default to self-only DM access. const defaultAllowFrom = - (!configuredAllowFrom || configuredAllowFrom.length === 0) && to + isWhatsAppSurface && (!configuredAllowFrom || configuredAllowFrom.length === 0) && to ? [to] : undefined; const allowFrom = @@ -819,10 +868,12 @@ export async function getReplyFromConfig( : rawBodyNormalized; const activationCommand = parseActivationCommand(commandBodyNormalized); const senderE164 = normalizeE164(ctx.SenderE164 ?? ""); - const ownerCandidates = (allowFrom ?? []).filter( - (entry) => entry && entry !== "*", - ); - if (ownerCandidates.length === 0 && to) ownerCandidates.push(to); + const ownerCandidates = isWhatsAppSurface + ? (allowFrom ?? []).filter((entry) => entry && entry !== "*") + : []; + if (isWhatsAppSurface && ownerCandidates.length === 0 && to) { + ownerCandidates.push(to); + } const ownerList = ownerCandidates .map((entry) => normalizeE164(entry)) .filter((entry): entry is string => Boolean(entry)); @@ -833,20 +884,6 @@ export async function getReplyFromConfig( 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 || ""} not in allowFrom list`, - ); - cleanupTyping(); - return undefined; - } - } - if (activationCommand.hasCommand) { if (!isGroup) { cleanupTyping(); @@ -1038,8 +1075,7 @@ export async function getReplyFromConfig( // 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. const isGroupSession = - typeof ctx.From === "string" && - (ctx.From.includes("@g.us") || ctx.From.startsWith("group:")); + sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room"; const isMainSession = !isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main"); if (isMainSession) { diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 0ca8852ef..249fc6277 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -63,8 +63,9 @@ describe("buildStatusMessage", () => { sessionId: "g1", updatedAt: 0, groupActivation: "always", + chatType: "group", }, - sessionKey: "group:123@g.us", + sessionKey: "whatsapp:group:123@g.us", sessionScope: "per-sender", webLinked: true, }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index dbef495d2..4024841ef 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -191,7 +191,13 @@ export function buildStatusMessage(args: StatusArgs): string { .filter(Boolean) .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"}` : undefined; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 00dc4a9a7..2215a127b 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -2,6 +2,7 @@ export type MsgContext = { Body?: string; From?: string; To?: string; + SessionKey?: string; MessageSid?: string; ReplyToId?: string; ReplyToBody?: string; @@ -12,6 +13,8 @@ export type MsgContext = { Transcript?: string; ChatType?: string; GroupSubject?: string; + GroupRoom?: string; + GroupSpace?: string; GroupMembers?: string; SenderName?: string; SenderE164?: string; diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 621a797bf..ace1fb6ed 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36 \ No newline at end of file +969df6da368b3a802bf0f7f34bf2e30102ae51d91daf45f1fb9328877e2fb335 diff --git a/src/cli/program.ts b/src/cli/program.ts index b53f4137d..926094987 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -14,6 +14,7 @@ import { danger, setVerbose } from "../globals.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; +import { readConfigFileSnapshot } from "../config/config.js"; import { registerBrowserCli } from "./browser-cli.js"; import { registerCanvasCli } from "./canvas-cli.js"; import { registerCronCli } from "./cron-cli.js"; @@ -68,6 +69,21 @@ export function buildProgram() { } 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 = [ [ "clawdis login --verbose", diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 5edd1c45d..d62398f27 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -158,7 +158,7 @@ export async function agentCommand( }); const workspaceDir = workspace.dir; - const allowFrom = (cfg.routing?.allowFrom ?? []) + const allowFrom = (cfg.whatsapp?.allowFrom ?? []) .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); @@ -451,7 +451,7 @@ export async function agentCommand( if (deliver) { if (deliveryProvider === "whatsapp" && !whatsappTarget) { const err = new Error( - "Delivering to WhatsApp requires --to or routing.allowFrom[0]", + "Delivering to WhatsApp requires --to or whatsapp.allowFrom[0]", ); if (!bestEffortDeliver) throw err; logDeliveryError(err); @@ -470,7 +470,7 @@ export async function agentCommand( } if (deliveryProvider === "signal" && !signalTarget) { const err = new Error( - "Delivering to Signal requires --to ", + "Delivering to Signal requires --to ", ); if (!bestEffortDeliver) throw err; logDeliveryError(err); diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 35f6d5967..c0096344f 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -39,6 +39,7 @@ import { printWizardHeader, probeGatewayReachable, randomToken, + resolveControlUiLinks, summarizeExistingConfig, } from "./onboard-helpers.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."); } diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts new file mode 100644 index 000000000..000e6c01c --- /dev/null +++ b/src/commands/doctor.test.ts @@ -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, + 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) => 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)?.allowFrom).toEqual([ + "+15555550123", + ]); + expect(written.routing).toBeUndefined(); + }); +}); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 8e52b0d24..22696e9e8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,6 +4,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdisConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDIS, + migrateLegacyConfig, readConfigFileSnapshot, writeConfigFile, } from "../config/config.js"; @@ -29,10 +30,36 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { const snapshot = await readConfigFileSnapshot(); 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"); } + 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( cfg.agent?.workspace ?? DEFAULT_WORKSPACE, ); @@ -57,7 +84,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); healthOk = true; } 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) { @@ -79,7 +111,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { try { await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); } 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}`); + } } } } diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 4e9b98ba5..024be2646 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { inspect } from "node:util"; 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 { resolveSessionTranscriptsDir } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; +import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; @@ -195,7 +197,14 @@ export async function probeGatewayReachable(params: { } 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 = raw .split("\n") @@ -205,3 +214,20 @@ function summarizeError(err: unknown): string { } 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}`, + }; +} diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 758ddaf61..4b14de7f3 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -20,7 +20,6 @@ import { import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; -import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; @@ -40,6 +39,7 @@ import { printWizardHeader, probeGatewayReachable, randomToken, + resolveControlUiLinks, summarizeExistingConfig, } from "./onboard-helpers.js"; import { setupProviders } from "./onboard-providers.js"; @@ -280,8 +280,16 @@ export async function runInteractiveOnboarding( await select({ message: "Gateway auth", 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" }, ], }), @@ -344,6 +352,7 @@ export async function runInteractiveOnboarding( const tokenInput = guardCancel( await text({ message: "Gateway token (blank to generate)", + placeholder: "Needed for multi-machine or non-loopback access", initialValue: randomToken(), }), runtime, @@ -375,7 +384,11 @@ export async function runInteractiveOnboarding( ...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( (() => { - const tailnetIPv4 = pickPrimaryTailnetIPv4(); - const host = - bind === "tailnet" || (bind === "auto" && tailnetIPv4) - ? (tailnetIPv4 ?? "127.0.0.1") - : "127.0.0.1"; + const links = resolveControlUiLinks({ bind, port }); + const tokenParam = + authMode === "token" && gatewayToken + ? `?token=${encodeURIComponent(gatewayToken)}` + : ""; + const authedUrl = `${links.httpUrl}${tokenParam}`; return [ - `Control UI: http://${host}:${port}/`, - `Gateway WS: ws://${host}:${port}`, - ].join("\n"); + `Web UI: ${links.httpUrl}`, + tokenParam ? `Web UI (with token): ${authedUrl}` : undefined, + `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."); } diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 1876d8dbe..af537ec06 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -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 { + 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( cfg: ClawdisConfig, runtime: RuntimeEnv, @@ -198,70 +285,7 @@ export async function setupProviders( note("Run `clawdis login` later to link WhatsApp.", "WhatsApp"); } - const existingAllowFrom = cfg.routing?.allowFrom ?? []; - 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 }, - }; - } - } + next = await promptWhatsAppAllowFrom(next, runtime); } if (selection.includes("telegram")) { @@ -277,7 +301,15 @@ export async function setupProviders( }), runtime, ); - if (!keepEnv) { + if (keepEnv) { + next = { + ...next, + telegram: { + ...next.telegram, + enabled: true, + }, + }; + } else { token = String( guardCancel( await text({ @@ -344,7 +376,15 @@ export async function setupProviders( }), runtime, ); - if (!keepEnv) { + if (keepEnv) { + next = { + ...next, + discord: { + ...next.discord, + enabled: true, + }, + }; + } else { token = String( guardCancel( await text({ diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index ce99b3142..5365625c1 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -22,6 +22,21 @@ function summarizeInstallFailure(message: string): string | undefined { 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( cfg: ClawdisConfig, skillKey: string, @@ -104,7 +119,7 @@ export async function setupSkills( ...installable.map((skill) => ({ value: skill.name, label: `${skill.emoji ?? "🧩"} ${skill.name}`, - hint: skill.install[0]?.label ?? "install", + hint: formatSkillHint(skill), })), ], }), diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 3d1fbd273..1ee0f8e68 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -77,7 +77,7 @@ describe("sessionsCommand", () => { it("shows placeholder rows when tokens are missing", async () => { const store = writeStore({ - "group:demo": { + "discord:group:demo": { sessionId: "xyz", updatedAt: Date.now() - 5 * 60_000, thinkingLevel: "high", @@ -89,7 +89,7 @@ describe("sessionsCommand", () => { 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("think:high"); expect(row).toContain("5m ago"); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 538470016..7c91ecfa2 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -119,10 +119,17 @@ const formatAge = (ms: number | null | undefined) => { 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.startsWith("group:")) return "group"; 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"; } @@ -132,7 +139,7 @@ function toRows(store: Record): SessionRow[] { const updatedAt = entry?.updatedAt ?? null; return { key, - kind: classifyKey(key), + kind: classifyKey(key, entry), updatedAt, ageMs: updatedAt ? Date.now() - updatedAt : null, sessionId: entry?.sessionId, diff --git a/src/commands/status.ts b/src/commands/status.ts index 456249495..ddbb45914 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -102,7 +102,7 @@ export async function getStatusSummary(): Promise { return { key, - kind: classifyKey(key), + kind: classifyKey(key, entry), sessionId: entry?.sessionId, updatedAt, age, @@ -169,10 +169,20 @@ const formatContextUsage = ( 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.startsWith("group:")) return "group"; 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"; }; diff --git a/src/config/config.test.ts b/src/config/config.test.ts index d007254a9..4d46782fe 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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("U3: isNixMode env var detection", () => { it("isNixMode is false when CLAWDIS_NIX_MODE is not set", async () => { @@ -430,3 +488,48 @@ describe("talk.voiceAliases", () => { 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"); + }); + }); +}); diff --git a/src/config/config.ts b/src/config/config.ts index 8aea48a51..cd979570b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -58,6 +58,11 @@ export type WebConfig = { reconnect?: WebReconnectConfig; }; +export type WhatsAppConfig = { + /** Optional allowlist for WhatsApp direct chats (E.164). */ + allowFrom?: string[]; +}; + export type BrowserConfig = { enabled?: boolean; /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */ @@ -164,21 +169,52 @@ export type TelegramConfig = { 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; + /** If true, allow group DMs (default: false). */ + groupEnabled?: boolean; + /** Optional allowlist for group DM channels (ids or slugs). */ + groupChannels?: Array; +}; + +export type DiscordGuildChannelConfig = { + allow?: boolean; + requireMention?: boolean; +}; + +export type DiscordGuildEntry = { + slug?: string; + requireMention?: boolean; + users?: Array; + channels?: Record; +}; + +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 = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; token?: string; - allowFrom?: Array; - guildAllowFrom?: { - guilds?: Array; - users?: Array; - }; - requireMention?: boolean; mediaMaxMb?: number; - /** Number of recent guild messages to include for context (default: 20). */ historyLimit?: number; /** Allow agent-triggered Discord reactions (default: true). */ enableReactions?: boolean; + slashCommand?: DiscordSlashCommandConfig; + dm?: DiscordDmConfig; + /** New per-guild config keyed by guild id or slug. */ + guilds?: Record; }; export type SignalConfig = { @@ -241,7 +277,6 @@ export type GroupChatConfig = { }; export type RoutingConfig = { - allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) transcribeAudio?: { // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. command: string[]; @@ -316,6 +351,8 @@ export type GatewayAuthMode = "token" | "password"; export type GatewayAuthConfig = { /** Authentication mode for Gateway connections. Defaults to token when set. */ mode?: GatewayAuthMode; + /** Shared token for token mode (stored locally for CLI auth). */ + token?: string; /** Shared password for password mode (consider env instead). */ password?: string; /** Allow Tailscale identity headers when serve mode is enabled. */ @@ -506,6 +543,7 @@ export type ClawdisConfig = { messages?: MessagesConfig; session?: SessionConfig; web?: WebConfig; + whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; signal?: SignalConfig; @@ -674,7 +712,6 @@ const HeartbeatSchema = z const RoutingSchema = z .object({ - allowFrom: z.array(z.string()).optional(), groupChat: GroupChatSchema, transcribeAudio: TranscribeAudioSchema, queue: z @@ -890,6 +927,11 @@ const ClawdisSchema = z.object({ .optional(), }) .optional(), + whatsapp: z + .object({ + allowFrom: z.array(z.string()).optional(), + }) + .optional(), telegram: z .object({ enabled: z.boolean().optional(), @@ -908,17 +950,61 @@ const ClawdisSchema = z.object({ .object({ enabled: z.boolean().optional(), token: z.string().optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - guildAllowFrom: z + slashCommand: z .object({ - guilds: z.array(z.union([z.string(), z.number()])).optional(), - users: z.array(z.union([z.string(), z.number()])).optional(), + enabled: z.boolean().optional(), + name: z.string().optional(), + sessionPrefix: z.string().optional(), + ephemeral: z.boolean().optional(), }) .optional(), - requireMention: z.boolean().optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).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(), signal: z @@ -1013,6 +1099,7 @@ const ClawdisSchema = z.object({ auth: z .object({ mode: z.union([z.literal("token"), z.literal("password")]).optional(), + token: z.string().optional(), password: z.string().optional(), allowTailscale: z.boolean().optional(), }) @@ -1076,6 +1163,11 @@ export type ConfigValidationIssue = { message: string; }; +export type LegacyConfigIssue = { + path: string; + message: string; +}; + export type ConfigFileSnapshot = { path: string; exists: boolean; @@ -1084,8 +1176,100 @@ export type ConfigFileSnapshot = { valid: boolean; config: ClawdisConfig; issues: ConfigValidationIssue[]; + legacyIssues: LegacyConfigIssue[]; }; +type LegacyConfigRule = { + path: string[]; + message: string; +}; + +type LegacyConfigMigration = { + id: string; + describe: string; + apply: (raw: Record, 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).allowFrom; + if (allowFrom === undefined) return; + + const whatsapp = + raw.whatsapp && typeof raw.whatsapp === "object" + ? (raw.whatsapp as Record) + : {}; + + 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).allowFrom; + if (Object.keys(routing as Record).length === 0) { + delete raw.routing; + } + raw.whatsapp = whatsapp; + }, + }, +]; + +function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { + if (!raw || typeof raw !== "object") return []; + const root = raw as Record; + 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)[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; + 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 { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -1144,6 +1328,16 @@ export function validateConfigObject( ): | { ok: true; config: ClawdisConfig } | { 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); if (!validated.success) { return { @@ -1216,6 +1410,7 @@ export async function readConfigFileSnapshot(): Promise { const exists = fs.existsSync(configPath); if (!exists) { const config = applyTalkApiKey({}); + const legacyIssues: LegacyConfigIssue[] = []; return { path: configPath, exists: false, @@ -1224,6 +1419,7 @@ export async function readConfigFileSnapshot(): Promise { valid: true, config, issues: [], + legacyIssues, }; } @@ -1241,9 +1437,12 @@ export async function readConfigFileSnapshot(): Promise { issues: [ { path: "", message: `JSON5 parse failed: ${parsedRes.error}` }, ], + legacyIssues: [], }; } + const legacyIssues = findLegacyConfigIssues(parsedRes.parsed); + const validated = validateConfigObject(parsedRes.parsed); if (!validated.ok) { return { @@ -1254,6 +1453,7 @@ export async function readConfigFileSnapshot(): Promise { valid: false, config: {}, issues: validated.issues, + legacyIssues, }; } @@ -1265,6 +1465,7 @@ export async function readConfigFileSnapshot(): Promise { valid: true, config: applyTalkApiKey(validated.config), issues: [], + legacyIssues, }; } catch (err) { return { @@ -1275,6 +1476,7 @@ export async function readConfigFileSnapshot(): Promise { valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], + legacyIssues: [], }; } } diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index dcab9cb14..6422f0b45 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + buildGroupDisplayName, deriveSessionKey, loadSessionStore, 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", () => { expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main"); }); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 449638101..7457bb0d2 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -10,11 +10,24 @@ import { normalizeE164 } from "../utils.js"; 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 = { sessionId: string; updatedAt: number; systemSent?: boolean; abortedLastRun?: boolean; + chatType?: SessionChatType; thinkingLevel?: string; verboseLevel?: string; providerOverride?: string; @@ -27,6 +40,11 @@ export type SessionEntry = { totalTokens?: number; model?: string; contextTokens?: number; + displayName?: string; + surface?: string; + subject?: string; + room?: string; + space?: string; lastChannel?: | "whatsapp" | "telegram" @@ -38,6 +56,14 @@ export type SessionEntry = { skillsSnapshot?: SessionSkillSnapshot; }; +export type GroupKeyResolution = { + key: string; + legacyKey?: string; + surface?: string; + id?: string; + chatType?: SessionChatType; +}; + export type SessionSkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; @@ -66,6 +92,151 @@ export function resolveStorePath(store?: string) { 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( storePath: string, ): Record { @@ -145,6 +316,12 @@ export async function updateLastRoute(params: { totalTokens: existing?.totalTokens, model: existing?.model, contextTokens: existing?.contextTokens, + displayName: existing?.displayName, + chatType: existing?.chatType, + surface: existing?.surface, + subject: existing?.subject, + room: existing?.room, + space: existing?.space, skillsSnapshot: existing?.skillsSnapshot, lastChannel: channel, 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). export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { if (scope === "global") return "global"; + const resolvedGroup = resolveGroupSessionKey(ctx); + if (resolvedGroup) return resolvedGroup.key; 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"; } @@ -177,11 +349,16 @@ export function resolveSessionKey( ctx: MsgContext, mainKey?: string, ) { + const explicit = ctx.SessionKey?.trim(); + if (explicit) return explicit; const raw = deriveSessionKey(scope, ctx); if (scope === "global") return raw; // Default to a single shared direct-chat session called "main"; groups stay isolated. 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; return raw; } diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index bd74efc9a..2ed808e91 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -103,7 +103,7 @@ function resolveDeliveryTarget( const sanitizedWhatsappTo = (() => { if (channel !== "whatsapp") return to; - const rawAllow = cfg.routing?.allowFrom ?? []; + const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) return to; const allowFrom = rawAllow .map((val) => normalizeE164(val)) diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index 210e2860a..a40a698bd 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -23,4 +23,21 @@ describe("cron schedule", () => { ); 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); + }); }); diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 4c4308da8..bdf92ea13 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -12,9 +12,9 @@ export function computeNextRunAtMs( if (schedule.kind === "every") { const everyMs = Math.max(1, Math.floor(schedule.everyMs)); 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 steps = Math.floor((elapsed + everyMs - 1) / everyMs); + const steps = Math.max(1, Math.floor((elapsed + everyMs - 1) / everyMs)); return anchor + steps * everyMs; } diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts new file mode 100644 index 000000000..5d1d087d5 --- /dev/null +++ b/src/discord/monitor.test.ts @@ -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>, +): Record => { + const out: Record = {}; + 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); + }); +}); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 880e0f8cc..e5449074a 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,5 +1,8 @@ import { + ApplicationCommandOptionType, + ChannelType, Client, + type CommandInteractionOption, Events, GatewayIntentBits, type Message, @@ -9,10 +12,12 @@ import { import { chunkText } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.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 { DiscordSlashCommandConfig } from "../config/config.js"; import { loadConfig } from "../config/config.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 { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; @@ -24,12 +29,7 @@ export type MonitorDiscordOpts = { token?: string; runtime?: RuntimeEnv; abortSignal?: AbortSignal; - allowFrom?: Array; - guildAllowFrom?: { - guilds?: Array; - users?: Array; - }; - requireMention?: boolean; + slashCommand?: DiscordSlashCommandConfig; mediaMaxMb?: number; historyLimit?: number; }; @@ -47,6 +47,25 @@ type DiscordHistoryEntry = { messageId?: string; }; +export type DiscordAllowList = { + allowAll: boolean; + ids: Set; + names: Set; +}; + +export type DiscordGuildEntryResolved = { + id?: string; + slug?: string; + requireMention?: boolean; + users?: Array; + channels?: Record; +}; + +export type DiscordChannelConfigResolved = { + allowed: boolean; + requireMention?: boolean; +}; + export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = loadConfig(); const token = normalizeDiscordToken( @@ -69,16 +88,21 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }, }; - const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom; - const guildAllowFrom = opts.guildAllowFrom ?? cfg.discord?.guildAllowFrom; - const requireMention = - opts.requireMention ?? cfg.discord?.requireMention ?? true; + const dmConfig = cfg.discord?.dm; + const guildEntries = cfg.discord?.guilds; + const allowFrom = dmConfig?.allowFrom; + const slashCommand = resolveSlashCommandConfig( + opts.slashCommand ?? cfg.discord?.slashCommand, + ); const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const historyLimit = Math.max( 0, opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, ); + const dmEnabled = dmConfig?.enabled ?? true; + const groupDmEnabled = dmConfig?.groupEnabled ?? false; + const groupDmChannels = dmConfig?.groupChannels; const client = new Client({ intents: [ @@ -95,6 +119,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { client.once(Events.ClientReady, () => { runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`); + if (slashCommand.enabled) { + void ensureSlashCommand(client, slashCommand, runtime); + } }); client.on(Events.Error, (err) => { @@ -106,7 +133,13 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (message.author?.bot) 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 wasMentioned = !isDirectMessage && Boolean(botId && message.mentions.has(botId)); @@ -117,7 +150,59 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { 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) ?? []; history.push({ sender: message.member?.displayName ?? message.author.tag, @@ -129,7 +214,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { guildHistories.set(message.channelId, history); } - if (!isDirectMessage && requireMention) { + const resolvedRequireMention = + channelConfig?.requireMention ?? guildInfo?.requireMention ?? true; + if (isGuildMessage && resolvedRequireMention) { if (botId && !wasMentioned) { logger.info( { @@ -142,23 +229,23 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } } - if (!isDirectMessage && guildAllowFrom) { - const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [ - "guild:", - ]); - const users = normalizeDiscordAllowList(guildAllowFrom.users, [ - "discord:", - "user:", - ]); - if (guilds || users) { - const guildId = message.guild?.id ?? ""; - const userId = message.author.id; - const guildOk = - !guilds || guilds.allowAll || (guildId && guilds.ids.has(guildId)); - const userOk = !users || users.allowAll || users.ids.has(userId); - if (!guildOk || !userOk) { + if (isGuildMessage) { + const userAllow = guildInfo?.users; + if (Array.isArray(userAllow) && userAllow.length > 0) { + const users = normalizeDiscordAllowList(userAllow, [ + "discord:", + "user:", + ]); + const userOk = + !users || + allowListMatches(users, { + id: message.author.id, + name: message.author.username, + tag: message.author.tag, + }); + if (!userOk) { 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; } @@ -166,22 +253,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) { - const allowed = allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean); - const candidate = message.author.id; - const normalized = new Set( - allowed - .filter((entry) => entry !== "*") - .map((entry) => entry.replace(/^discord:/i, "")), - ); + const allowList = normalizeDiscordAllowList(allowFrom, [ + "discord:", + "user:", + ]); const permitted = - allowed.includes("*") || - normalized.has(candidate) || - allowed.includes(candidate); + allowList && + allowListMatches(allowList, { + id: message.author.id, + name: message.author.username, + tag: message.author.tag, + }); if (!permitted) { logVerbose( - `Blocked unauthorized discord sender ${candidate} (not in allowFrom)`, + `Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`, ); return; } @@ -198,6 +283,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const fromLabel = isDirectMessage ? buildDirectLabel(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}]`; let combinedBody = formatAgentEnvelope({ surface: "Discord", @@ -224,7 +312,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { .join("\n"); 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; } @@ -238,10 +328,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { : `channel:${message.channelId}`, ChatType: isDirectMessage ? "direct" : "group", SenderName: message.member?.displayName ?? message.author.tag, - GroupSubject: - !isDirectMessage && "name" in message.channel - ? message.channel.name - : undefined, + GroupSubject: groupSubject, + GroupRoom: groupRoom, + GroupSpace: isGuildMessage ? guildSlug || undefined : undefined, Surface: "discord" as const, WasMentioned: wasMentioned, MessageSid: message.id, @@ -290,7 +379,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { token, runtime, }); - if (!isDirectMessage && shouldClearHistory && historyLimit > 0) { + if (isGuildMessage && shouldClearHistory && historyLimit > 0) { guildHistories.set(message.channelId, []); } } 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 new Promise((resolve, reject) => { @@ -364,25 +610,256 @@ function buildGuildLabel(message: import("discord.js").Message) { return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`; } -function normalizeDiscordAllowList( +export function normalizeDiscordAllowList( raw: Array | undefined, prefixes: string[], -): { allowAll: boolean; ids: Set } | null { +): DiscordAllowList | null { if (!raw || raw.length === 0) return null; - const cleaned = raw - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => { - for (const prefix of prefixes) { - if (entry.toLowerCase().startsWith(prefix)) { - return entry.slice(prefix.length); - } + const ids = new Set(); + const names = new Set(); + let allowAll = false; + + for (const rawEntry of raw) { + let entry = String(rawEntry).trim(); + 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 | 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 | 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, + 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("*"); - const ids = new Set(cleaned.filter((entry) => entry !== "*")); - return { allowAll, ids }; + runtime.log?.(`registered discord slash command /${slashCommand.name}`); + } catch (err) { + 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 { + 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) { @@ -430,3 +907,45 @@ async function deliverReplies({ 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 }); + } +} diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index cb179e7d5..b2ec9324e 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean { export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { if (auth.mode === "token" && !auth.token) { 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) { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index edecfa285..32399258e 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -25,8 +25,8 @@ export async function callGateway( ): Promise { const timeoutMs = opts.timeoutMs ?? 10_000; const config = loadConfig(); - const remote = - config.gateway?.mode === "remote" ? config.gateway.remote : undefined; + const isRemoteMode = config.gateway?.mode === "remote"; + const remote = isRemoteMode ? config.gateway.remote : undefined; const url = (typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() @@ -39,9 +39,15 @@ export async function callGateway( (typeof opts.token === "string" && opts.token.trim().length > 0 ? opts.token.trim() : undefined) || - (typeof remote?.token === "string" && remote.token.trim().length > 0 - ? remote.token.trim() - : undefined); + (isRemoteMode + ? typeof remote?.token === "string" && remote.token.trim().length > 0 + ? 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 = (typeof opts.password === "string" && opts.password.trim().length > 0 ? opts.password.trim() diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 1e246c280..2bdb753c2 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -130,6 +130,11 @@ let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined; let testGatewayAuth: Record | undefined; let testHooksConfig: Record | undefined; let testCanvasHostPort: number | undefined; +let testLegacyIssues: Array<{ path: string; message: string }> = []; +let testLegacyParsed: Record = {}; +let testMigrationConfig: Record | null = null; +let testMigrationChanges: string[] = []; +let testIsNixMode = false; const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 })); vi.mock("../config/sessions.js", async () => { const actual = await vi.importActual( @@ -151,6 +156,21 @@ vi.mock("../config/config.js", () => { path.join(os.homedir(), ".clawdis", "clawdis.json"); 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(); try { await fs.access(configPath); @@ -163,6 +183,7 @@ vi.mock("../config/config.js", () => { valid: true, config: {}, issues: [], + legacyIssues: [], }; } try { @@ -176,6 +197,7 @@ vi.mock("../config/config.js", () => { valid: true, config: parsed, issues: [], + legacyIssues: [], }; } catch (err) { return { @@ -186,27 +208,32 @@ vi.mock("../config/config.js", () => { valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], + legacyIssues: [], }; } }; - const writeConfigFile = async (cfg: Record) => { + const writeConfigFile = vi.fn(async (cfg: Record) => { const configPath = resolveConfigPath(); await fs.mkdir(path.dirname(configPath), { recursive: true }); const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); await fs.writeFile(configPath, raw, "utf-8"); - }; + }); return { CONFIG_PATH_CLAWDIS: resolveConfigPath(), STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), - isNixMode: false, + isNixMode: testIsNixMode, + migrateLegacyConfig: (raw: unknown) => ({ + config: testMigrationConfig ?? (raw as Record), + changes: testMigrationChanges, + }), loadConfig: () => ({ agent: { model: "anthropic/claude-opus-4-5", workspace: path.join(os.tmpdir(), "clawd-gateway-test"), }, - routing: { + whatsapp: { allowFrom: testAllowFrom, }, session: { mainKey: "main", store: testSessionStorePath }, @@ -279,6 +306,11 @@ beforeEach(async () => { testGatewayAuth = undefined; testHooksConfig = undefined; testCanvasHostPort = undefined; + testLegacyIssues = []; + testLegacyParsed = {}; + testMigrationConfig = null; + testMigrationChanges = []; + testIsNixMode = false; cronIsolatedRun.mockClear(); drainSystemEvents(); 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 () => { piSdkMock.enabled = true; piSdkMock.models = [ @@ -3865,7 +3931,7 @@ describe("gateway server", () => { thinkingLevel: "low", verboseLevel: "on", }, - "group:dev": { + "discord:group:dev": { sessionId: "sess-group", updatedAt: now - 120_000, totalTokens: 50, @@ -3977,7 +4043,7 @@ describe("gateway server", () => { const deleted = await rpcReq<{ ok: true; deleted: boolean }>( ws, "sessions.delete", - { key: "group:dev" }, + { key: "discord:group:dev" }, ); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); @@ -3986,7 +4052,9 @@ describe("gateway server", () => { }>(ws, "sessions.list", {}); expect(listAfterDelete.ok).toBe(true); expect( - listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev"), + listAfterDelete.payload?.sessions.some( + (s) => s.key === "discord:group:dev", + ), ).toBe(false); const filesAfterDelete = await fs.readdir(dir); expect( diff --git a/src/gateway/server.ts b/src/gateway/server.ts index a8f1589c4..e3b89bf2e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -48,6 +48,7 @@ import { CONFIG_PATH_CLAWDIS, isNixMode, loadConfig, + migrateLegacyConfig, parseConfigJson5, readConfigFileSnapshot, STATE_DIR_CLAWDIS, @@ -55,6 +56,7 @@ import { writeConfigFile, } from "../config/config.js"; import { + buildGroupDisplayName, loadSessionStore, resolveStorePath, type SessionEntry, @@ -455,6 +457,11 @@ type GatewaySessionsDefaults = { type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; + displayName?: string; + surface?: string; + subject?: string; + room?: string; + space?: string; updatedAt: number | null; sessionId?: string; systemSent?: boolean; @@ -653,7 +660,6 @@ type DedupeEntry = { error?: ErrorShape; }; -const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN; function formatForLog(value: unknown): string { try { @@ -862,13 +868,41 @@ function loadSessionEntry(sessionKey: string) { 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.startsWith("group:")) return "group"; 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"; } +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 { const resolved = resolveConfiguredModelRef({ cfg, @@ -913,9 +947,32 @@ function listSessionsFromStore(params: { const input = entry?.inputTokens ?? 0; const output = entry?.outputTokens ?? 0; 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 { key, - kind: classifySessionKey(key), + kind: classifySessionKey(key, entry), + displayName, + surface, + subject, + room, + space, updatedAt, sessionId: entry?.sessionId, systemSent: entry?.systemSent, @@ -1265,6 +1322,31 @@ export async function startGatewayServer( port = 18789, opts: GatewayServerOptions = {}, ): Promise { + 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 bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback"; const bindHost = opts.host ?? resolveGatewayBindHost(bindMode); @@ -1288,7 +1370,8 @@ export async function startGatewayServer( ...tailscaleOverrides, }; const tailscaleMode = tailscaleConfig.mode ?? "off"; - const token = getGatewayToken(); + const token = + authConfig.token ?? process.env.CLAWDIS_GATEWAY_TOKEN ?? undefined; const password = authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined; const authMode: ResolvedGatewayAuth["mode"] = @@ -2017,6 +2100,15 @@ export async function startGatewayServer( const startTelegramProvider = async () => { if (telegramTask) return; 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) { telegramRuntime = { ...telegramRuntime, @@ -2111,6 +2203,15 @@ export async function startGatewayServer( const startDiscordProvider = async () => { if (discordTask) return; 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) { discordRuntime = { ...discordRuntime, @@ -2153,9 +2254,7 @@ export async function startGatewayServer( token: discordToken.trim(), runtime: discordRuntimeEnv, abortSignal: discordAbort.signal, - allowFrom: cfg.discord?.allowFrom, - guildAllowFrom: cfg.discord?.guildAllowFrom, - requireMention: cfg.discord?.requireMention, + slashCommand: cfg.discord?.slashCommand, mediaMaxMb: cfg.discord?.mediaMaxMb, historyLimit: cfg.discord?.historyLimit, }) @@ -2216,6 +2315,26 @@ export async function startGatewayServer( logSignal.info("skipping provider start (signal.enabled=false)"); 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 port = cfg.signal?.httpPort ?? 8080; const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`; @@ -2881,6 +3000,12 @@ export async function startGatewayServer( verboseLevel: entry?.verboseLevel, model: entry?.model, contextTokens: entry?.contextTokens, + displayName: entry?.displayName, + chatType: entry?.chatType, + surface: entry?.surface, + subject: entry?.subject, + room: entry?.room, + space: entry?.space, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, @@ -4285,21 +4410,33 @@ export async function startGatewayServer( ? Math.max(1000, timeoutMsRaw) : 10_000; const cfg = loadConfig(); + const telegramCfg = cfg.telegram; + const telegramEnabled = + Boolean(telegramCfg) && telegramCfg?.enabled !== false; const { token: telegramToken, source: tokenSource } = - resolveTelegramToken(cfg); + telegramEnabled + ? resolveTelegramToken(cfg) + : { token: "", source: "none" as const }; let telegramProbe: TelegramProbe | undefined; let lastProbeAt: number | null = null; - if (probe && telegramToken) { + if (probe && telegramToken && telegramEnabled) { telegramProbe = await probeTelegram( telegramToken, timeoutMs, - cfg.telegram?.proxy, + telegramCfg?.proxy, ); lastProbeAt = Date.now(); } - const discordEnvToken = process.env.DISCORD_BOT_TOKEN?.trim(); - const discordConfigToken = cfg.discord?.token?.trim(); + const discordCfg = cfg.discord; + 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 discordTokenSource = discordEnvToken ? "env" @@ -4308,7 +4445,7 @@ export async function startGatewayServer( : "none"; let discordProbe: DiscordProbe | undefined; let discordLastProbeAt: number | null = null; - if (probe && discordToken) { + if (probe && discordToken && discordEnabled) { discordProbe = await probeDiscord(discordToken, timeoutMs); discordLastProbeAt = Date.now(); } @@ -4320,7 +4457,17 @@ export async function startGatewayServer( const signalBaseUrl = signalCfg?.httpUrl?.trim() || `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 signalLastProbeAt: number | null = null; if (probe && signalConfigured) { @@ -4362,7 +4509,7 @@ export async function startGatewayServer( lastError: whatsappRuntime.lastError ?? null, }, telegram: { - configured: Boolean(telegramToken), + configured: telegramEnabled && Boolean(telegramToken), tokenSource, running: telegramRuntime.running, mode: telegramRuntime.mode ?? null, @@ -4373,7 +4520,7 @@ export async function startGatewayServer( lastProbeAt, }, discord: { - configured: Boolean(discordToken), + configured: discordEnabled && Boolean(discordToken), tokenSource: discordTokenSource, running: discordRuntime.running, lastStartAt: discordRuntime.lastStartAt ?? null, @@ -6521,7 +6668,7 @@ export async function startGatewayServer( if (explicit) return resolvedTo; const cfg = cfgForAgent ?? loadConfig(); - const rawAllow = cfg.routing?.allowFrom ?? []; + const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) return resolvedTo; const allowFrom = rawAllow .map((val) => normalizeE164(val)) diff --git a/src/imessage/client.ts b/src/imessage/client.ts index 6a89f8c5b..efcdae28f 100644 --- a/src/imessage/client.ts +++ b/src/imessage/client.ts @@ -168,8 +168,9 @@ export class IMessageRpcClient { let parsed: IMessageRpcResponse; try { parsed = JSON.parse(line) as IMessageRpcResponse; - } catch (_err) { - this.runtime?.error?.(`imsg rpc: failed to parse ${line}`); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + this.runtime?.error?.(`imsg rpc: failed to parse ${line}: ${detail}`); return; } diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 33e0ca95d..bbc0fcb36 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -61,8 +61,7 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv { function resolveAllowFrom(opts: MonitorIMessageOpts): string[] { const cfg = loadConfig(); - const raw = - opts.allowFrom ?? cfg.imessage?.allowFrom ?? cfg.routing?.allowFrom ?? []; + const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? []; return raw.map((entry) => String(entry).trim()).filter(Boolean); } diff --git a/src/imessage/targets.ts b/src/imessage/targets.ts index eaa5c8428..c1f1def41 100644 --- a/src/imessage/targets.ts +++ b/src/imessage/targets.ts @@ -52,6 +52,23 @@ export function parseIMessageTarget(raw: string): IMessageTarget { if (!trimmed) throw new Error("iMessage target is required"); const lower = trimmed.toLowerCase(); + for (const { prefix, service } of SERVICE_PREFIXES) { + if (lower.startsWith(prefix)) { + const remainder = stripPrefix(trimmed, prefix); + if (!remainder) throw new Error(`${prefix} target is required`); + const remainderLower = remainder.toLowerCase(); + const isChatTarget = + CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || + CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) || + CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) || + remainderLower.startsWith("group:"); + if (isChatTarget) { + return parseIMessageTarget(remainder); + } + return { kind: "handle", to: remainder, service }; + } + } + for (const prefix of CHAT_ID_PREFIXES) { if (lower.startsWith(prefix)) { const value = stripPrefix(trimmed, prefix); @@ -89,14 +106,6 @@ export function parseIMessageTarget(raw: string): IMessageTarget { return { kind: "chat_guid", chatGuid: value }; } - for (const { prefix, service } of SERVICE_PREFIXES) { - if (lower.startsWith(prefix)) { - const to = stripPrefix(trimmed, prefix); - if (!to) throw new Error(`${prefix} target is required`); - return { kind: "handle", to, service }; - } - } - return { kind: "handle", to: trimmed, service: "auto" }; } @@ -105,6 +114,14 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget { if (!trimmed) return { kind: "handle", handle: "" }; const lower = trimmed.toLowerCase(); + for (const { prefix } of SERVICE_PREFIXES) { + if (lower.startsWith(prefix)) { + const remainder = stripPrefix(trimmed, prefix); + if (!remainder) return { kind: "handle", handle: "" }; + return parseIMessageAllowTarget(remainder); + } + } + for (const prefix of CHAT_ID_PREFIXES) { if (lower.startsWith(prefix)) { const value = stripPrefix(trimmed, prefix); diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 001b66319..c2927dd8f 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -94,7 +94,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { it("applies allowFrom fallback for WhatsApp targets", () => { const cfg: ClawdisConfig = { agent: { heartbeat: { target: "whatsapp", to: "+1999" } }, - routing: { allowFrom: ["+1555", "+1666"] }, + whatsapp: { allowFrom: ["+1555", "+1666"] }, }; const entry = { ...baseEntry, @@ -145,7 +145,7 @@ describe("runHeartbeatOnce", () => { agent: { heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }; @@ -206,7 +206,7 @@ describe("runHeartbeatOnce", () => { agent: { heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, }, - routing: { allowFrom: ["*"] }, + whatsapp: { allowFrom: ["*"] }, session: { store: storePath }, }; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b94d77f7b..822f537ee 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -235,7 +235,7 @@ export function resolveHeartbeatDeliveryTarget(params: { return { channel, to }; } - const rawAllow = cfg.routing?.allowFrom ?? []; + const rawAllow = cfg.whatsapp?.allowFrom ?? []; if (rawAllow.includes("*")) return { channel, to }; const allowFrom = rawAllow .map((val) => normalizeE164(val)) @@ -401,7 +401,7 @@ export async function runHeartbeatOnce(opts: { const startedAt = opts.deps?.nowMs?.() ?? Date.now(); const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg); const previousUpdatedAt = entry?.updatedAt; - const allowFrom = cfg.routing?.allowFrom ?? []; + const allowFrom = cfg.whatsapp?.allowFrom ?? []; const sender = resolveHeartbeatSender({ allowFrom, lastTo: entry?.lastTo, diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index 70186d4ef..e471f0118 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -35,8 +35,11 @@ export async function buildProviderSummary( if (!telegramEnabled) { lines.push(chalk.cyan("Telegram: disabled")); } else { - const { token: telegramToken } = resolveTelegramToken(effective); - const telegramConfigured = Boolean(telegramToken); + const { token: telegramToken } = effective.telegram + ? resolveTelegramToken(effective) + : { token: "" }; + const telegramConfigured = + Boolean(effective.telegram) && Boolean(telegramToken); lines.push( telegramConfigured ? chalk.green("Telegram: configured") @@ -48,11 +51,16 @@ export async function buildProviderSummary( if (!signalEnabled) { lines.push(chalk.cyan("Signal: disabled")); } else { - const signalConfigured = Boolean( - effective.signal?.httpUrl || - effective.signal?.cliPath || - effective.signal?.account, - ); + const signalConfigured = + Boolean(effective.signal) && + Boolean( + effective.signal?.account?.trim() || + effective.signal?.httpUrl?.trim() || + effective.signal?.cliPath?.trim() || + effective.signal?.httpHost?.trim() || + typeof effective.signal?.httpPort === "number" || + typeof effective.signal?.autoStart === "boolean", + ); lines.push( signalConfigured ? chalk.green("Signal: configured") @@ -72,8 +80,8 @@ export async function buildProviderSummary( ); } - const allowFrom = effective.routing?.allowFrom?.length - ? effective.routing.allowFrom.map(normalizeE164).filter(Boolean) + const allowFrom = effective.whatsapp?.allowFrom?.length + ? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean) : []; if (allowFrom.length) { lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`)); diff --git a/src/signal/daemon.test.ts b/src/signal/daemon.test.ts index 4940fc8b7..134605f10 100644 --- a/src/signal/daemon.test.ts +++ b/src/signal/daemon.test.ts @@ -16,6 +16,15 @@ describe("classifySignalCliLogLine", () => { expect(classifySignalCliLogLine("ERROR Something")).toBe("error"); }); + it("treats failures without explicit severity as error", () => { + expect( + classifySignalCliLogLine("Failed to initialize HTTP Server - oops"), + ).toBe("error"); + expect(classifySignalCliLogLine('Exception in thread "main"')).toBe( + "error", + ); + }); + it("returns null for empty lines", () => { expect(classifySignalCliLogLine("")).toBe(null); expect(classifySignalCliLogLine(" ")).toBe(null); diff --git a/src/signal/daemon.ts b/src/signal/daemon.ts index 0bd382f9c..ca1b01b60 100644 --- a/src/signal/daemon.ts +++ b/src/signal/daemon.ts @@ -23,6 +23,8 @@ export function classifySignalCliLogLine(line: string): "log" | "error" | null { if (!trimmed) return null; // signal-cli commonly writes all logs to stderr; treat severity explicitly. if (/\b(ERROR|WARN|WARNING)\b/.test(trimmed)) return "error"; + // Some signal-cli failures are not tagged with WARN/ERROR but should still be surfaced loudly. + if (/\b(FAILED|SEVERE|EXCEPTION)\b/i.test(trimmed)) return "error"; return "log"; } diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 90763acd4..2c1e52902 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -92,8 +92,7 @@ function resolveAccount(opts: MonitorSignalOpts): string | undefined { function resolveAllowFrom(opts: MonitorSignalOpts): string[] { const cfg = loadConfig(); - const raw = - opts.allowFrom ?? cfg.signal?.allowFrom ?? cfg.routing?.allowFrom ?? []; + const raw = opts.allowFrom ?? cfg.signal?.allowFrom ?? []; return raw.map((entry) => String(entry).trim()).filter(Boolean); } diff --git a/src/signal/send.ts b/src/signal/send.ts index c82ae8ce0..b05765e0e 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -43,19 +43,20 @@ function parseTarget(raw: string): SignalTarget { let value = raw.trim(); if (!value) throw new Error("Signal recipient is required"); const lower = value.toLowerCase(); - if (lower.startsWith("group:")) { - return { type: "group", groupId: value.slice("group:".length).trim() }; - } if (lower.startsWith("signal:")) { value = value.slice("signal:".length).trim(); } - if (lower.startsWith("username:")) { + const normalized = value.toLowerCase(); + if (normalized.startsWith("group:")) { + return { type: "group", groupId: value.slice("group:".length).trim() }; + } + if (normalized.startsWith("username:")) { return { type: "username", username: value.slice("username:".length).trim(), }; } - if (lower.startsWith("u:")) { + if (normalized.startsWith("u:")) { return { type: "username", username: value.trim() }; } return { type: "recipient", recipient: value }; diff --git a/src/telegram/send.ts b/src/telegram/send.ts index ad9ac64e9..06b50da5b 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -36,7 +36,7 @@ function normalizeChatId(to: string): string { // Common internal prefixes that sometimes leak into outbound sends. // - ctx.To uses `telegram:` - // - group sessions often use `group:` + // - group sessions often use `telegram:group:` let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); // Accept t.me links for public chats/channels. diff --git a/src/utils.ts b/src/utils.ts index 6882209a7..737159f24 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -33,7 +33,7 @@ export function normalizeE164(number: string): string { /** * "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account, - * and `routing.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the + * and `whatsapp.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the * "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers). */ export function isSelfChatMode( diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 11657ab91..4cc2df39b 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -111,7 +111,7 @@ describe("partial reply gating", () => { const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); const mockConfig: ClawdisConfig = { - routing: { + whatsapp: { allowFrom: ["*"], }, }; @@ -158,7 +158,7 @@ describe("partial reply gating", () => { const replyResolver = vi.fn().mockResolvedValue(undefined); const mockConfig: ClawdisConfig = { - routing: { + whatsapp: { allowFrom: ["*"], }, session: { store: store.storePath, mainKey: "main" }, @@ -1015,7 +1015,7 @@ describe("web auto-reply", () => { .mockResolvedValueOnce({ text: "ok" }); const { storePath, cleanup } = await makeSessionStore({ - "group:123@g.us": { + "whatsapp:group:123@g.us": { sessionId: "g-1", updatedAt: Date.now(), groupActivation: "always", @@ -1097,9 +1097,11 @@ describe("web auto-reply", () => { const resolver = vi.fn().mockResolvedValue({ text: "ok" }); setLoadConfigMock(() => ({ - routing: { + whatsapp: { // Self-chat heuristic: allowFrom includes selfE164. allowFrom: ["+999"], + }, + routing: { groupChat: { requireMention: true, mentionPatterns: ["\\bclawd\\b"], @@ -1247,7 +1249,7 @@ describe("web auto-reply", () => { it("prefixes body with same-phone marker when from === to", async () => { // Enable messagePrefix for same-phone mode testing setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -1372,7 +1374,7 @@ describe("web auto-reply", () => { it("applies responsePrefix to regular replies", async () => { setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -1417,7 +1419,7 @@ describe("web auto-reply", () => { it("does not deliver HEARTBEAT_OK responses", async () => { setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -1462,7 +1464,7 @@ describe("web auto-reply", () => { it("does not double-prefix if responsePrefix already present", async () => { setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -1508,7 +1510,7 @@ describe("web auto-reply", () => { it("sends tool summaries immediately with responsePrefix", async () => { setLoadConfigMock(() => ({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 74ff33072..628254100 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -116,7 +116,7 @@ function buildMentionConfig(cfg: ReturnType): MentionConfig { } }) .filter((r): r is RegExp => Boolean(r)) ?? []; - return { mentionRegexes, allowFrom: cfg.routing?.allowFrom }; + return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom }; } function isBotMentioned( @@ -412,7 +412,10 @@ function getSessionRecipients(cfg: ReturnType) { const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const isGroupKey = (key: string) => - key.startsWith("group:") || key.includes("@g.us"); + key.startsWith("group:") || + key.includes(":group:") || + key.includes(":channel:") || + key.includes("@g.us"); const isCronKey = (key: string) => key.startsWith("cron:"); const recipients = Object.entries(store) @@ -445,8 +448,8 @@ export function resolveHeartbeatRecipients( const sessionRecipients = getSessionRecipients(cfg); const allowFrom = - Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0 - ? cfg.routing.allowFrom.filter((v) => v !== "*").map(normalizeE164) + Array.isArray(cfg.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0 + ? cfg.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164) : []; const unique = (list: string[]) => [...new Set(list.filter(Boolean))]; @@ -812,7 +815,7 @@ export async function monitorWebProvider( const resolveGroupActivationFor = (conversationId: string) => { const key = conversationId.startsWith("group:") ? conversationId - : `group:${conversationId}`; + : `whatsapp:group:${conversationId}`; const store = loadSessionStore(sessionStorePath); const entry = store[key]; const requireMention = cfg.routing?.groupChat?.requireMention; @@ -915,7 +918,7 @@ export async function monitorWebProvider( // Build message prefix: explicit config > default based on allowFrom let messagePrefix = cfg.messages?.messagePrefix; if (messagePrefix === undefined) { - const hasAllowFrom = (cfg.routing?.allowFrom?.length ?? 0) > 0; + const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0; messagePrefix = hasAllowFrom ? "" : "[clawdis]"; } const prefixStr = messagePrefix ? `${messagePrefix} ` : ""; diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index 175cad76d..d350bdad7 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; vi.mock("../config/config.js", () => ({ loadConfig: vi.fn().mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], // Allow all in tests }, messages: { diff --git a/src/web/inbound.ts b/src/web/inbound.ts index aa96077b6..ba2c4e6ba 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -157,7 +157,7 @@ export async function monitorWebInbox(options: { // Filter unauthorized senders early to prevent wasted processing // and potential session corruption from Bad MAC errors const cfg = loadConfig(); - const configuredAllowFrom = cfg.routing?.allowFrom; + const configuredAllowFrom = cfg.whatsapp?.allowFrom; // Without user config, default to self-only DM access so the owner can talk to themselves const defaultAllowFrom = (!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164 diff --git a/src/web/media.test.ts b/src/web/media.test.ts index a1618d5b5..e5a6f394c 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -80,12 +80,34 @@ describe("web media loading", () => { // Create a minimal valid GIF (1x1 pixel) // GIF89a header + minimal image data const gifBuffer = Buffer.from([ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a - 0x01, 0x00, 0x01, 0x00, // 1x1 dimensions - 0x00, 0x00, 0x00, // no global color table - 0x2c, 0x00, 0x00, 0x00, 0x00, // image descriptor - 0x01, 0x00, 0x01, 0x00, 0x00, // 1x1 image - 0x02, 0x01, 0x44, 0x00, 0x3b, // minimal LZW data + trailer + 0x47, + 0x49, + 0x46, + 0x38, + 0x39, + 0x61, // GIF89a + 0x01, + 0x00, + 0x01, + 0x00, // 1x1 dimensions + 0x00, + 0x00, + 0x00, // no global color table + 0x2c, + 0x00, + 0x00, + 0x00, + 0x00, // image descriptor + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, // 1x1 image + 0x02, + 0x01, + 0x44, + 0x00, + 0x3b, // minimal LZW data + trailer ]); const file = path.join(os.tmpdir(), `clawdis-media-${Date.now()}.gif`); @@ -102,18 +124,19 @@ describe("web media loading", () => { it("preserves GIF from URL without JPEG conversion", async () => { const gifBytes = new Uint8Array([ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, - 0x01, 0x00, 0x01, 0x00, - 0x00, 0x00, 0x00, - 0x2c, 0x00, 0x00, 0x00, 0x00, - 0x01, 0x00, 0x01, 0x00, 0x00, - 0x02, 0x01, 0x44, 0x00, 0x3b, + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, + 0x01, 0x44, 0x00, 0x3b, ]); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, body: true, - arrayBuffer: async () => gifBytes.buffer.slice(gifBytes.byteOffset, gifBytes.byteOffset + gifBytes.byteLength), + arrayBuffer: async () => + gifBytes.buffer.slice( + gifBytes.byteOffset, + gifBytes.byteOffset + gifBytes.byteLength, + ), headers: { get: () => "image/gif" }, status: 200, } as Response); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index b4ee36cb2..e26ec3035 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -10,7 +10,7 @@ vi.mock("../media/store.js", () => ({ })); const mockLoadConfig = vi.fn().mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], // Allow all in tests by default }, messages: { @@ -450,7 +450,7 @@ describe("web monitor inbox", () => { it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => { mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+111"], // does not include +777 }, messages: { @@ -506,7 +506,7 @@ describe("web monitor inbox", () => { // Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors // from unauthorized senders corrupting sessions mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+111"], // Only allow +111 }, messages: { @@ -546,7 +546,7 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -561,7 +561,7 @@ describe("web monitor inbox", () => { it("skips read receipts in self-chat mode", async () => { mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { // Self-chat heuristic: allowFrom includes selfE164 (+123). allowFrom: ["+123"], }, @@ -598,7 +598,7 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -613,7 +613,7 @@ describe("web monitor inbox", () => { it("lets group messages through even when sender not in allowFrom", async () => { mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+1234"], }, messages: { @@ -655,7 +655,7 @@ describe("web monitor inbox", () => { it("allows messages from senders in allowFrom list", async () => { mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+111", "+999"], // Allow +999 }, messages: { @@ -690,7 +690,7 @@ describe("web monitor inbox", () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { @@ -707,7 +707,7 @@ describe("web monitor inbox", () => { // Same-phone mode: when from === selfJid, should always be allowed // This allows users to message themselves even with restrictive allowFrom mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["+111"], // Only allow +111, but self is +123 }, messages: { @@ -810,7 +810,7 @@ it("defaults to self-only when no config is present", async () => { // Reset mock for other tests mockLoadConfig.mockReturnValue({ - routing: { + whatsapp: { allowFrom: ["*"], }, messages: { diff --git a/src/web/test-helpers.ts b/src/web/test-helpers.ts index 21a6a5b9d..09cf84c57 100644 --- a/src/web/test-helpers.ts +++ b/src/web/test-helpers.ts @@ -6,7 +6,7 @@ import { createMockBaileys } from "../../test/mocks/baileys.js"; // Use globalThis to store the mock config so it survives vi.mock hoisting const CONFIG_KEY = Symbol.for("clawdis:testConfigMock"); const DEFAULT_CONFIG = { - routing: { + whatsapp: { // Tests can override; default remains open to avoid surprising fixtures allowFrom: ["*"], }, diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 1cfa2e3ef..b376d83d2 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -1,54 +1,60 @@ -@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap"); :root { - --bg: #0a0e14; - --bg-accent: #101826; - --bg-grad-1: #1a2740; - --bg-grad-2: #241626; - --bg-overlay: rgba(255, 255, 255, 0.03); - --bg-glow: rgba(54, 207, 201, 0.08); - --panel: rgba(18, 24, 36, 0.92); - --panel-strong: rgba(24, 32, 46, 0.95); - --chrome: rgba(10, 14, 20, 0.75); - --chrome-strong: rgba(10, 14, 20, 0.82); - --text: rgba(246, 248, 252, 0.95); - --chat-text: rgba(246, 248, 252, 0.88); - --muted: rgba(210, 218, 230, 0.62); - --border: rgba(255, 255, 255, 0.08); - --accent: #ff7a3d; - --accent-2: #36cfc9; - --ok: #1bd98a; + --bg: #0a0f14; + --bg-accent: #111826; + --bg-grad-1: #162031; + --bg-grad-2: #1f2a22; + --bg-overlay: rgba(255, 255, 255, 0.05); + --bg-glow: rgba(245, 159, 74, 0.12); + --panel: rgba(14, 20, 30, 0.88); + --panel-strong: rgba(18, 26, 38, 0.96); + --chrome: rgba(9, 14, 20, 0.72); + --chrome-strong: rgba(9, 14, 20, 0.86); + --text: rgba(244, 246, 251, 0.96); + --chat-text: rgba(231, 237, 244, 0.92); + --muted: rgba(156, 169, 189, 0.72); + --border: rgba(255, 255, 255, 0.09); + --border-strong: rgba(255, 255, 255, 0.16); + --accent: #f59f4a; + --accent-2: #34c7b7; + --ok: #2bd97f; --warn: #f2c94c; - --danger: #ff5c5c; + --danger: #ff6b6b; + --focus: rgba(245, 159, 74, 0.35); + --grid-line: rgba(255, 255, 255, 0.04); --theme-switch-x: 50%; --theme-switch-y: 50%; - --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --font-body: "Space Grotesk", system-ui, sans-serif; - --font-display: "Fraunces", "Times New Roman", serif; + --font-body: "Work Sans", system-ui, sans-serif; + --font-display: "Unbounded", "Times New Roman", serif; color-scheme: dark; } :root[data-theme="light"] { - --bg: #f5f7fb; + --bg: #f5f1ea; --bg-accent: #ffffff; - --bg-grad-1: #e3edf9; - --bg-grad-2: #f7e6f0; - --bg-overlay: rgba(28, 32, 46, 0.04); - --bg-glow: rgba(54, 207, 201, 0.12); + --bg-grad-1: #f1e6d6; + --bg-grad-2: #e5eef4; + --bg-overlay: rgba(28, 32, 46, 0.05); + --bg-glow: rgba(52, 199, 183, 0.14); --panel: rgba(255, 255, 255, 0.9); - --panel-strong: rgba(255, 255, 255, 0.98); - --chrome: rgba(255, 255, 255, 0.72); - --chrome-strong: rgba(255, 255, 255, 0.82); - --text: rgba(20, 24, 36, 0.96); - --chat-text: rgba(20, 24, 36, 0.82); - --muted: rgba(50, 58, 76, 0.6); - --border: rgba(16, 24, 40, 0.12); - --accent: #ff7a3d; - --accent-2: #1bb9b1; - --ok: #15b97a; - --warn: #c58a1a; - --danger: #e84343; + --panel-strong: rgba(255, 255, 255, 0.97); + --chrome: rgba(255, 255, 255, 0.75); + --chrome-strong: rgba(255, 255, 255, 0.88); + --text: rgba(27, 36, 50, 0.98); + --chat-text: rgba(36, 48, 66, 0.9); + --muted: rgba(80, 94, 114, 0.7); + --border: rgba(18, 24, 40, 0.12); + --border-strong: rgba(18, 24, 40, 0.2); + --accent: #e28a3f; + --accent-2: #1ba99d; + --ok: #1aa86c; + --warn: #b3771c; + --danger: #d44848; + --focus: rgba(226, 138, 63, 0.35); + --grid-line: rgba(18, 24, 40, 0.06); color-scheme: light; } @@ -63,16 +69,13 @@ body { body { margin: 0; - font: 15px/1.4 var(--font-body); - background: radial-gradient( - 1200px 900px at 20% 0%, - var(--bg-grad-1) 0%, - var(--bg) 55% - ) + font: 15px/1.5 var(--font-body); + background: + radial-gradient(1200px 900px at 15% -10%, var(--bg-grad-1) 0%, transparent 55%) fixed, - radial-gradient(900px 700px at 90% 10%, var(--bg-grad-2) 0%, transparent 55%) + radial-gradient(900px 700px at 80% 10%, var(--bg-grad-2) 0%, transparent 60%) fixed, - var(--bg); + linear-gradient(160deg, var(--bg) 0%, var(--bg-accent) 100%) fixed; color: var(--text); } @@ -80,16 +83,37 @@ body::before { content: ""; position: fixed; inset: 0; - background: linear-gradient( - 135deg, + background: + linear-gradient( + 140deg, var(--bg-overlay) 0%, - rgba(255, 255, 255, 0) 35% + rgba(255, 255, 255, 0) 40% ), - radial-gradient( - 600px 400px at 80% 80%, - var(--bg-glow), - transparent 60% + radial-gradient(620px 420px at 75% 75%, var(--bg-glow), transparent 60%); + pointer-events: none; + z-index: 0; +} + +body::after { + content: ""; + position: fixed; + inset: 0; + background: + repeating-linear-gradient( + 90deg, + var(--grid-line) 0, + var(--grid-line) 1px, + transparent 1px, + transparent 140px + ), + repeating-linear-gradient( + 0deg, + var(--grid-line) 0, + var(--grid-line) 1px, + transparent 1px, + transparent 140px ); + opacity: 0.45; pointer-events: none; z-index: 0; } @@ -160,3 +184,14 @@ select { transform: translateY(0); } } + +@keyframes dashboard-enter { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 1c459bed3..5a0e155d4 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,34 +1,38 @@ .card { border: 1px solid var(--border); - background: var(--panel); - border-radius: 18px; + background: linear-gradient(160deg, rgba(255, 255, 255, 0.04), transparent 65%), + var(--panel); + border-radius: 16px; padding: 16px; - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25); - animation: rise 0.35s ease; + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28); + animation: rise 0.4s ease; } .card-title { font-family: var(--font-display); - font-size: 18px; + font-size: 16px; + letter-spacing: 0.6px; + text-transform: uppercase; } .card-sub { color: var(--muted); - font-size: 13px; + font-size: 12px; } .stat { - background: var(--panel-strong); + background: linear-gradient(140deg, rgba(255, 255, 255, 0.04), transparent 70%), + var(--panel-strong); border-radius: 14px; padding: 12px; - border: 1px solid rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-strong); } .stat-label { color: var(--muted); - font-size: 12px; + font-size: 11px; text-transform: uppercase; - letter-spacing: 0.8px; + letter-spacing: 1px; } .stat-value { @@ -51,6 +55,7 @@ .note-title { font-weight: 600; + letter-spacing: 0.2px; } .status-list { @@ -72,19 +77,20 @@ .label { color: var(--muted); - font-size: 12px; + font-size: 11px; text-transform: uppercase; - letter-spacing: 0.6px; + letter-spacing: 0.9px; } .pill { display: inline-flex; align-items: center; gap: 8px; - border: 1px solid var(--border); - padding: 6px 10px; + border: 1px solid var(--border-strong); + padding: 6px 12px; border-radius: 999px; - background: var(--panel); + background: linear-gradient(160deg, rgba(255, 255, 255, 0.06), transparent), + var(--panel); } .theme-toggle { @@ -101,8 +107,8 @@ gap: var(--theme-gap); padding: var(--theme-pad); border-radius: 999px; - border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-strong); + background: rgba(255, 255, 255, 0.04); } .theme-toggle__indicator { @@ -114,10 +120,12 @@ border-radius: 999px; transform: translateY(-50%) translateX(calc(var(--theme-index, 0) * (var(--theme-item) + var(--theme-gap)))); - background: var(--panel-strong); - border: 1px solid var(--border); - box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25); - transition: transform 180ms ease-out, background 180ms ease-out; + background: linear-gradient(160deg, rgba(255, 255, 255, 0.12), transparent), + var(--panel-strong); + border: 1px solid var(--border-strong); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.25); + transition: transform 180ms ease-out, background 180ms ease-out, + box-shadow 180ms ease-out; z-index: 0; } @@ -170,28 +178,31 @@ .statusDot.ok { background: var(--ok); + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25), 0 0 10px rgba(43, 217, 127, 0.4); } .btn { - border: 1px solid var(--border); + border: 1px solid var(--border-strong); background: rgba(255, 255, 255, 0.04); - padding: 8px 12px; - border-radius: 10px; + padding: 8px 14px; + border-radius: 999px; cursor: pointer; + transition: transform 150ms ease, border-color 150ms ease, background 150ms ease; } .btn:hover { - background: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.1); + transform: translateY(-1px); } .btn.primary { - border-color: rgba(255, 122, 61, 0.35); - background: rgba(255, 122, 61, 0.18); + border-color: rgba(245, 159, 74, 0.45); + background: rgba(245, 159, 74, 0.2); } .btn.danger { - border-color: rgba(255, 92, 92, 0.4); - background: rgba(255, 92, 92, 0.16); + border-color: rgba(255, 107, 107, 0.45); + background: rgba(255, 107, 107, 0.18); } .field { @@ -201,17 +212,45 @@ .field span { color: var(--muted); - font-size: 12px; + font-size: 11px; + letter-spacing: 0.4px; } .field input, .field textarea, .field select { - border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.3); - border-radius: 10px; - padding: 8px 10px; + border: 1px solid var(--border-strong); + background: rgba(0, 0, 0, 0.22); + border-radius: 12px; + padding: 9px 11px; outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease, + background 150ms ease; +} + +.field input:focus, +.field textarea:focus, +.field select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--focus); + background: rgba(0, 0, 0, 0.28); +} + +.field select { + appearance: none; + padding-right: 38px; + background-color: var(--panel-strong); + background-image: + linear-gradient(45deg, transparent 50%, var(--muted) 50%), + linear-gradient(135deg, var(--muted) 50%, transparent 50%), + linear-gradient(to right, transparent, transparent); + background-position: + calc(100% - 18px) 50%, + calc(100% - 12px) 50%, + calc(100% - 38px) 50%; + background-size: 6px 6px, 6px 6px, 1px 60%; + background-repeat: no-repeat; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); } .field textarea { @@ -221,6 +260,10 @@ white-space: pre; } +.field textarea:focus { + background: rgba(0, 0, 0, 0.32); +} + .field.checkbox { grid-template-columns: auto 1fr; align-items: center; @@ -232,6 +275,19 @@ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } +:root[data-theme="light"] .field input, +:root[data-theme="light"] .field textarea, +:root[data-theme="light"] .field select { + background: rgba(255, 255, 255, 0.9); + border-color: var(--border-strong); +} + +:root[data-theme="light"] .field input:focus, +:root[data-theme="light"] .field textarea:focus, +:root[data-theme="light"] .field select:focus { + background: #ffffff; +} + .muted { color: var(--muted); } @@ -242,9 +298,10 @@ .callout { padding: 10px 12px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: linear-gradient(160deg, rgba(255, 255, 255, 0.06), transparent), + rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); } .callout.danger { @@ -257,12 +314,19 @@ font-size: 12px; background: rgba(0, 0, 0, 0.35); padding: 10px; - border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + border: 1px solid var(--border); max-height: 360px; overflow: auto; } +:root[data-theme="light"] .code-block, +:root[data-theme="light"] .list-item, +:root[data-theme="light"] .table-row, +:root[data-theme="light"] .chip { + background: rgba(255, 255, 255, 0.85); +} + .list { display: grid; gap: 12px; @@ -291,13 +355,13 @@ .list-sub { color: var(--muted); - font-size: 13px; + font-size: 12px; } .list-meta { text-align: right; color: var(--muted); - font-size: 12px; + font-size: 11px; display: grid; gap: 4px; min-width: 220px; @@ -316,7 +380,7 @@ } .chip { - font-size: 12px; + font-size: 11px; border: 1px solid var(--border); border-radius: 999px; padding: 4px 8px; @@ -348,7 +412,7 @@ } .table-head { - font-size: 12px; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--muted); @@ -397,11 +461,11 @@ padding: 14px 12px; min-width: 0; border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border); background: linear-gradient( 180deg, - rgba(0, 0, 0, 0.18) 0%, - rgba(0, 0, 0, 0.26) 100% + rgba(0, 0, 0, 0.2) 0%, + rgba(0, 0, 0, 0.3) 100% ); } @@ -440,10 +504,10 @@ .chat-bubble { border: 1px solid var(--border); background: rgba(0, 0, 0, 0.24); - border-radius: 18px; + border-radius: 16px; padding: 10px 12px; min-width: 0; - box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22); + box-shadow: 0 12px 22px rgba(0, 0, 0, 0.24); } :root[data-theme="light"] .chat-bubble { @@ -452,20 +516,20 @@ } .chat-line.user .chat-bubble { - border-color: rgba(255, 122, 61, 0.35); + border-color: rgba(245, 159, 74, 0.45); background: linear-gradient( 135deg, - rgba(255, 122, 61, 0.24) 0%, - rgba(255, 122, 61, 0.12) 100% + rgba(245, 159, 74, 0.26) 0%, + rgba(245, 159, 74, 0.12) 100% ); } .chat-line.assistant .chat-bubble { - border-color: rgba(54, 207, 201, 0.16); + border-color: rgba(52, 199, 183, 0.2); background: linear-gradient( 135deg, - rgba(54, 207, 201, 0.08) 0%, - rgba(0, 0, 0, 0.22) 100% + rgba(52, 199, 183, 0.12) 0%, + rgba(0, 0, 0, 0.24) 100% ); } @@ -479,18 +543,18 @@ @keyframes chatStreamPulse { 0% { - box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22), 0 0 0 0 rgba(54, 207, 201, 0); + box-shadow: 0 12px 22px rgba(0, 0, 0, 0.24), 0 0 0 0 rgba(52, 199, 183, 0); } 60% { - box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22), 0 0 0 6px rgba(54, 207, 201, 0.06); + box-shadow: 0 12px 22px rgba(0, 0, 0, 0.24), 0 0 0 6px rgba(52, 199, 183, 0.08); } 100% { - box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22), 0 0 0 0 rgba(54, 207, 201, 0); + box-shadow: 0 12px 22px rgba(0, 0, 0, 0.24), 0 0 0 0 rgba(52, 199, 183, 0); } } .chat-bubble.streaming { - border-color: rgba(54, 207, 201, 0.32); + border-color: rgba(52, 199, 183, 0.4); animation: chatStreamPulse 1.6s ease-in-out infinite; } @@ -531,7 +595,7 @@ .chat-compose__field textarea { min-height: 72px; padding: 10px 12px; - border-radius: 14px; + border-radius: 16px; resize: vertical; white-space: pre-wrap; font-family: var(--font-body); @@ -562,7 +626,7 @@ margin-top: 12px; border-radius: 14px; background: rgba(0, 0, 0, 0.2); - border: 1px dashed rgba(255, 255, 255, 0.12); + border: 1px dashed rgba(255, 255, 255, 0.18); padding: 12px; display: inline-flex; } diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 1a81fa776..45e7073ec 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1,11 +1,14 @@ .shell { min-height: 100vh; display: grid; - grid-template-columns: 240px 1fr; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); grid-template-rows: auto 1fr; grid-template-areas: "topbar topbar" "nav content"; + gap: 18px; + padding: 18px; + animation: dashboard-enter 0.6s ease-out; } .topbar { @@ -13,21 +16,31 @@ display: flex; justify-content: space-between; align-items: center; - padding: 18px 24px; - border-bottom: 1px solid var(--border); - background: var(--chrome); - backdrop-filter: blur(16px); + padding: 16px 20px; + border: 1px solid var(--border); + border-radius: 18px; + background: linear-gradient(135deg, var(--chrome), rgba(255, 255, 255, 0.02)); + backdrop-filter: blur(18px); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28); +} + +.brand { + display: grid; + gap: 4px; } .brand-title { font-family: var(--font-display); - font-size: 22px; - letter-spacing: 0.4px; + font-size: 20px; + letter-spacing: 0.6px; + text-transform: uppercase; } .brand-sub { color: var(--muted); - font-size: 13px; + font-size: 12px; + letter-spacing: 1.2px; + text-transform: uppercase; } .topbar-status { @@ -38,49 +51,87 @@ .nav { grid-area: nav; - padding: 18px 16px; - border-right: 1px solid var(--border); - background: var(--chrome-strong); + padding: 16px; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--panel); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(18px); } .nav-group { margin-bottom: 18px; display: grid; gap: 6px; + padding-bottom: 12px; + border-bottom: 1px dashed rgba(255, 255, 255, 0.08); +} + +.nav-group:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; } .nav-label { - font-size: 11px; + font-size: 10px; text-transform: uppercase; - letter-spacing: 1.4px; + letter-spacing: 1.6px; color: var(--muted); } .nav-item { + position: relative; display: flex; align-items: center; justify-content: space-between; gap: 10px; - padding: 9px 12px; + padding: 10px 12px 10px 14px; border-radius: 12px; border: 1px solid transparent; - background: rgba(255, 255, 255, 0.03); + background: rgba(255, 255, 255, 0.02); color: var(--muted); cursor: pointer; + transition: border-color 160ms ease, background 160ms ease, color 160ms ease, + transform 160ms ease; +} + +.nav-item:hover { + color: var(--text); + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); +} + +.nav-item::before { + content: ""; + position: absolute; + left: 6px; + top: 50%; + width: 4px; + height: 60%; + border-radius: 999px; + transform: translateY(-50%); + background: transparent; } .nav-item.active { color: var(--text); - border-color: rgba(255, 122, 61, 0.45); - background: rgba(255, 122, 61, 0.14); + border-color: rgba(245, 159, 74, 0.45); + background: rgba(245, 159, 74, 0.16); + transform: translateX(2px); +} + +.nav-item.active::before { + background: var(--accent); + box-shadow: 0 0 12px rgba(245, 159, 74, 0.4); } .content { grid-area: content; - padding: 24px 28px 32px; + padding: 8px 6px 20px; display: flex; flex-direction: column; - gap: 18px; + gap: 20px; } .content-header { @@ -88,17 +139,19 @@ align-items: flex-end; justify-content: space-between; gap: 12px; + padding: 0 6px; } .page-title { font-family: var(--font-display); - font-size: 24px; - letter-spacing: 0.4px; + font-size: 26px; + letter-spacing: 0.6px; } .page-sub { color: var(--muted); - font-size: 13px; + font-size: 12px; + letter-spacing: 0.4px; } .page-meta { @@ -108,7 +161,7 @@ .grid { display: grid; - gap: 16px; + gap: 18px; } .grid-cols-2 { @@ -121,13 +174,13 @@ .stat-grid { display: grid; - gap: 12px; + gap: 14px; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } .note-grid { display: grid; - gap: 12px; + gap: 14px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } @@ -139,13 +192,13 @@ .stack { display: grid; - gap: 12px; + gap: 14px; } .filters { display: flex; flex-wrap: wrap; - gap: 12px; + gap: 10px; align-items: center; } @@ -157,6 +210,7 @@ "topbar" "nav" "content"; + padding: 12px; } .nav { @@ -164,12 +218,14 @@ gap: 16px; overflow-x: auto; border-right: none; - border-bottom: 1px solid var(--border); + padding: 12px; } .nav-group { grid-auto-flow: column; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + border-bottom: none; + padding-bottom: 0; } .grid-cols-2, diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index ffac48fcc..3c142e8e8 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -186,6 +186,7 @@ export class ClawdisApp extends LitElement { this.syncThemeWithSettings(); this.attachThemeListener(); window.addEventListener("popstate", this.popStateHandler); + this.applySettingsFromUrl(); this.connect(); this.startNodesPolling(); } @@ -334,6 +335,20 @@ export class ClawdisApp extends LitElement { } } + private applySettingsFromUrl() { + if (!window.location.search) return; + const params = new URLSearchParams(window.location.search); + const token = params.get("token")?.trim(); + if (!token) return; + if (!this.settings.token) { + this.applySettings({ ...this.settings, token }); + } + params.delete("token"); + const url = new URL(window.location.href); + url.search = params.toString(); + window.history.replaceState({}, "", url.toString()); + } + setTab(next: Tab) { if (this.tab !== next) this.tab = next; void this.refreshActiveTab(); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index da9d34b38..43f182e3f 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -90,4 +90,13 @@ describe("control UI routing", () => { expect(maxScroll).toBeGreaterThan(0); expect(container.scrollTop).toBe(maxScroll); }); + + it("hydrates token from URL params and strips it", async () => { + const app = mountApp("/ui/overview?token=abc123"); + await app.updateComplete; + + expect(app.settings.token).toBe("abc123"); + expect(window.location.pathname).toBe("/ui/overview"); + expect(window.location.search).toBe(""); + }); }); diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index f804a2564..1d2c36b5d 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -100,6 +100,11 @@ export type GatewaySessionsDefaults = { export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; + displayName?: string; + surface?: string; + subject?: string; + room?: string; + space?: string; updatedAt: number | null; sessionId?: string; systemSent?: boolean; diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 6eed6612e..c82526538 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -130,7 +130,7 @@ function renderRow(row: GatewaySessionRow, onPatch: SessionsProps["onPatch"]) { const verbose = row.verboseLevel ?? ""; return html`
-
${row.key}
+
${row.displayName ?? row.key}
${row.kind}
${updated}
${formatSessionTokens(row)}