From 7154bc6857ec16f1db59e378d16af9e698cd52d3 Mon Sep 17 00:00:00 2001 From: James Groat Date: Thu, 1 Jan 2026 17:27:43 -0700 Subject: [PATCH 01/55] fix(cron): prevent every schedule from firing in infinite loop When anchorMs is not provided (always in production), the schedule computed nextRunAtMs as nowMs, causing jobs to fire immediately and repeatedly instead of at the configured interval. - Change nowMs <= anchor to nowMs < anchor to prevent early return - Add Math.max(1, ...) to ensure steps is always at least 1 - Add test for anchorMs not provided case --- src/cron/schedule.test.ts | 8 ++++++++ src/cron/schedule.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index 210e2860a..f38bc6a2d 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -23,4 +23,12 @@ 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); + }); }); 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; } From 63a46a85f641c4438375834c5e07ee559c4ba474 Mon Sep 17 00:00:00 2001 From: Shadow Date: Thu, 1 Jan 2026 23:30:03 -0600 Subject: [PATCH 02/55] feat: pass discord id to clawd so it can ping users --- src/discord/monitor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 880e0f8cc..68be47c0e 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -224,7 +224,7 @@ 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}]`; + combinedBody = `${combinedBody}\n[from: ${message.member ? `${message.member.displayName} - ${message.member.user.id}` : `${message.author.username} - ${message.author.id}`}]`; shouldClearHistory = true; } From e5ee041d4ea9fbb94d7e19fe7c4e981d077d72db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 08:37:15 +0000 Subject: [PATCH 03/55] feat(skills): add Trello skill for board/list/card management --- skills/trello/SKILL.md | 84 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 skills/trello/SKILL.md diff --git a/skills/trello/SKILL.md b/skills/trello/SKILL.md new file mode 100644 index 000000000..51c129c84 --- /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":{"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: 100 requests per 10 seconds per token + +## 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}' +``` From 9adbf47773c832157d36e8316a3bccb5ca730685 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 10:14:58 +0100 Subject: [PATCH 04/55] refactor: normalize group session keys --- CHANGELOG.md | 1 + .../clawdis/node/chat/ChatController.kt | 3 +- .../steipete/clawdis/node/chat/ChatModels.kt | 1 + .../clawdis/node/ui/chat/ChatComposer.kt | 6 +- .../node/ui/chat/ChatSessionsDialog.kt | 3 +- .../Sources/Clawdis/ContextMenuCardView.swift | 2 +- apps/macos/Sources/Clawdis/SessionData.swift | 35 +++- .../Clawdis/SessionMenuLabelView.swift | 2 +- .../Sources/Clawdis/SessionsSettings.swift | 2 +- .../MenuSessionsInjectorTests.swift | 14 +- .../ClawdisIPCTests/SessionDataTests.swift | 8 +- .../WorkActivityStoreTests.swift | 8 +- .../Sources/ClawdisChatUI/ChatComposer.swift | 2 +- .../Sources/ClawdisChatUI/ChatSessions.swift | 5 + .../Sources/ClawdisChatUI/ChatSheets.swift | 2 +- .../Sources/ClawdisChatUI/ChatViewModel.swift | 5 + .../ClawdisKitTests/ChatViewModelTests.swift | 25 +++ docs/AGENTS.default.md | 2 +- docs/discord.md | 3 +- docs/grammy.md | 2 +- docs/group-messages.md | 4 +- docs/groups.md | 6 +- docs/session.md | 4 +- docs/sessions.md | 8 + docs/signal.md | 2 +- docs/surface.md | 2 +- docs/telegram.md | 4 +- docs/whatsapp.md | 2 +- src/auto-reply/reply.triggers.test.ts | 5 +- src/auto-reply/reply.ts | 47 ++++- src/auto-reply/status.test.ts | 3 +- src/auto-reply/status.ts | 8 +- src/commands/agent.ts | 2 +- src/commands/sessions.test.ts | 4 +- src/commands/sessions.ts | 13 +- src/commands/status.ts | 13 +- src/config/sessions.test.ts | 20 ++ src/config/sessions.ts | 175 +++++++++++++++++- src/discord/monitor.ts | 31 +++- src/gateway/server.test.ts | 8 +- src/gateway/server.ts | 69 ++++++- src/imessage/targets.ts | 33 +++- src/signal/send.ts | 11 +- src/telegram/send.ts | 2 +- src/web/auto-reply.test.ts | 2 +- src/web/auto-reply.ts | 7 +- ui/src/ui/types.ts | 5 + ui/src/ui/views/sessions.ts | 2 +- 48 files changed, 537 insertions(+), 86 deletions(-) create mode 100644 docs/sessions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5385b7c70..3485c1157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - `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. ### Features - Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. 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/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/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/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/discord.md b/docs/discord.md index 475525aa6..fc56fc680 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -11,7 +11,8 @@ 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:`. +- Group DMs are treated as group sessions (separate from `main`) and show up with a `discord:g-...` display label. - Keep routing deterministic: replies always go back to the surface they arrived on. ## How it works 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..94012b168 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -10,7 +10,7 @@ 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. +- 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. @@ -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..febde1f18 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. diff --git a/docs/session.md b/docs/session.md index 756bd5181..ca78efed8 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. + - 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..b386f9d6b 100644 --- a/docs/signal.md +++ b/docs/signal.md @@ -73,7 +73,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..06f298083 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -11,7 +11,7 @@ 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) @@ -23,7 +23,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - 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. +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: reuse `routing.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). ## Capabilities & limits (Bot API) diff --git a/docs/whatsapp.md b/docs/whatsapp.md index aa4b6a5b3..ee4a32837 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -52,7 +52,7 @@ 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. diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 62c77995b..3d84067d7 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -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)", diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 916c16d4a..17bc067c7 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -29,7 +29,9 @@ import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, DEFAULT_RESET_TRIGGERS, + buildGroupDisplayName, 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,16 @@ 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 +443,35 @@ 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 isRoomSurface = surface === "discord" || surface === "slack"; + const nextRoom = + 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; + 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); @@ -1038,8 +1078,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/commands/agent.ts b/src/commands/agent.ts index 5edd1c45d..64daf9a70 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -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/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..37462379f 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,17 @@ 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/sessions.test.ts b/src/config/sessions.test.ts index dcab9cb14..afec54a51 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -31,6 +31,26 @@ 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("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..0a4f7df60 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,135 @@ 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 detail = + params.room?.trim() || + params.subject?.trim() || + params.space?.trim() || + ""; + 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 = `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 +300,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 +318,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"; } @@ -181,7 +337,10 @@ export function resolveSessionKey( 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/discord/monitor.ts b/src/discord/monitor.ts index 880e0f8cc..135a4e52c 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,4 +1,5 @@ import { + ChannelType, Client, Events, GatewayIntentBits, @@ -106,7 +107,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (message.author?.bot) return; if (!message.author) return; - const isDirectMessage = !message.guild; + const channelType = message.channel.type; + const isGroupDm = channelType === ChannelType.GroupDM; + const isDirectMessage = channelType === ChannelType.DM; + const isGuildMessage = Boolean(message.guild); const botId = client.user?.id; const wasMentioned = !isDirectMessage && Boolean(botId && message.mentions.has(botId)); @@ -142,7 +146,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } } - if (!isDirectMessage && guildAllowFrom) { + if (!isDirectMessage && isGuildMessage && guildAllowFrom) { const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [ "guild:", ]); @@ -197,7 +201,16 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const fromLabel = isDirectMessage ? buildDirectLabel(message) - : buildGuildLabel(message); + : isGroupDm + ? buildGroupDmLabel(message) + : buildGuildLabel(message); + const groupSubject = (() => { + if (isDirectMessage) return undefined; + const channelName = + "name" in message.channel ? message.channel.name : message.channelId; + if (!channelName) return undefined; + return isGuildMessage ? `#${channelName}` : channelName; + })(); const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`; let combinedBody = formatAgentEnvelope({ surface: "Discord", @@ -238,10 +251,7 @@ 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, Surface: "discord" as const, WasMentioned: wasMentioned, MessageSid: message.id, @@ -358,6 +368,13 @@ function buildDirectLabel(message: import("discord.js").Message) { return `${username} id:${message.author.id}`; } +function buildGroupDmLabel(message: import("discord.js").Message) { + const channelName = + "name" in message.channel ? message.channel.name : undefined; + const name = channelName ? ` ${channelName}` : ""; + return `Group DM${name} id:${message.channelId}`; +} + function buildGuildLabel(message: import("discord.js").Message) { const channelName = "name" in message.channel ? message.channel.name : message.channelId; diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 1e246c280..cfac4af86 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -3865,7 +3865,7 @@ describe("gateway server", () => { thinkingLevel: "low", verboseLevel: "on", }, - "group:dev": { + "discord:group:dev": { sessionId: "sess-group", updatedAt: now - 120_000, totalTokens: 50, @@ -3977,7 +3977,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 +3986,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..e62841b34 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -55,6 +55,7 @@ import { writeConfigFile, } from "../config/config.js"; import { + buildGroupDisplayName, loadSessionStore, resolveStorePath, type SessionEntry, @@ -455,6 +456,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; @@ -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, @@ -2881,6 +2938,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, 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/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/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 11657ab91..05b217e7b 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -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", diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 74ff33072..762cb8135 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -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) @@ -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; 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)}
From d2e2077adae4ad30cd6e67c3242f28ef6db62521 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 10:23:40 +0100 Subject: [PATCH 05/55] fix: add top padding before first chat message --- CHANGELOG.md | 1 + apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3485c1157..e19a86900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ ### Fixes - Chat UI: keep the chat scrolled to the latest message after switching sessions. +- Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android). - 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. 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 From 0f56dce748cbe56c8c899a01cd7b21be32cf92ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 10:32:21 +0100 Subject: [PATCH 06/55] feat: add discord dm/guild allowlists --- CHANGELOG.md | 1 + docs/configuration.md | 22 ++++-- docs/discord.md | 43 ++++++----- src/config/config.ts | 47 +++++++++++- src/discord/monitor.ts | 167 ++++++++++++++++++++++++++++++++--------- 5 files changed, 217 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e19a86900..17f4ea6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 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 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. diff --git a/docs/configuration.md b/docs/configuration.md index 421bf6a04..4c9f7f054 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,15 +173,21 @@ 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 + dm: { + enabled: true, // disable all DMs when false + allowFrom: ["1234567890", "steipete"] // optional DM allowlist (ids or names) + }, + guild: { + channels: ["general", "help"], // optional channel allowlist (ids or names) + allowFrom: { + guilds: ["123456789012345678"], // optional guild allowlist (ids or names) + users: ["987654321098765432"] // optional user allowlist (ids or names) + }, + requireMention: true, // require @bot mentions in guilds + historyLimit: 20 // include last N guild messages as context + } } } ``` diff --git a/docs/discord.md b/docs/discord.md index fc56fc680..d914cab11 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -21,11 +21,12 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 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`). 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; disable with `discord.guild.requireMention = false` (legacy: `discord.requireMention`). +7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Legacy: `discord.allowFrom`. +8. Optional guild allowlist: set `discord.guild.allowFrom` with `guilds` and/or `users` (ids or names) to gate who can invoke the bot in servers. Legacy: `discord.guildAllowFrom`. +9. Optional guild channel allowlist: set `discord.guild.channels` with channel ids or names to restrict where the bot listens. +10. Optional guild context history: set `discord.guild.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable (legacy: `discord.historyLimit`). +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. @@ -42,24 +43,32 @@ 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, + dm: { + enabled: true, + allowFrom: ["123456789012345678", "steipete"] + }, + guild: { + channels: ["general", "help"], + allowFrom: { + guilds: ["123456789012345678", "My Server"], + users: ["987654321098765432", "steipete"] + }, + requireMention: true, + historyLimit: 20 + } } } ``` -- `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. +- `guild.allowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids or names). When both are set, both must match. +- `guild.channels`: Optional allowlist for channel ids or names. +- `guild.requireMention`: when `true`, messages in guild channels must mention the bot. - `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). +- `guild.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`). ## Reactions diff --git a/src/config/config.ts b/src/config/config.ts index 8aea48a51..7aa8b46d3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -164,21 +164,47 @@ 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; +}; + +export type DiscordGuildConfig = { + /** Allowlist for guild messages (guilds/users by id or name). */ + allowFrom?: { + guilds?: Array; + users?: Array; + }; + /** Allowlist for guild channels (ids or names). */ + channels?: Array; + /** Require @bot mention to respond in guilds. Default: true. */ + requireMention?: boolean; + /** Number of recent guild messages to include for context. */ + historyLimit?: number; +}; + export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; token?: string; + /** Legacy DM allowlist (ids). Prefer discord.dm.allowFrom. */ allowFrom?: Array; + /** Legacy guild allowlist (ids). Prefer discord.guild.allowFrom. */ guildAllowFrom?: { guilds?: Array; users?: Array; }; + /** Legacy mention requirement. Prefer discord.guild.requireMention. */ requireMention?: boolean; mediaMaxMb?: number; - /** Number of recent guild messages to include for context (default: 20). */ + /** Legacy history limit. Prefer discord.guild.historyLimit. */ historyLimit?: number; /** Allow agent-triggered Discord reactions (default: true). */ enableReactions?: boolean; + dm?: DiscordDmConfig; + guild?: DiscordGuildConfig; }; export type SignalConfig = { @@ -919,6 +945,25 @@ const ClawdisSchema = z.object({ 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(), + }) + .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 diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 135a4e52c..e6159c8d9 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -48,6 +48,12 @@ type DiscordHistoryEntry = { messageId?: string; }; +type DiscordAllowList = { + allowAll: boolean; + ids: Set; + names: Set; +}; + export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = loadConfig(); const token = normalizeDiscordToken( @@ -70,16 +76,28 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }, }; - const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom; - const guildAllowFrom = opts.guildAllowFrom ?? cfg.discord?.guildAllowFrom; + const dmConfig = cfg.discord?.dm; + const guildConfig = cfg.discord?.guild; + const allowFrom = + opts.allowFrom ?? dmConfig?.allowFrom ?? cfg.discord?.allowFrom; + const guildAllowFrom = + opts.guildAllowFrom ?? guildConfig?.allowFrom ?? cfg.discord?.guildAllowFrom; + const guildChannels = guildConfig?.channels; const requireMention = - opts.requireMention ?? cfg.discord?.requireMention ?? true; + opts.requireMention ?? + guildConfig?.requireMention ?? + cfg.discord?.requireMention ?? + true; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const historyLimit = Math.max( 0, - opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, + opts.historyLimit ?? + guildConfig?.historyLimit ?? + cfg.discord?.historyLimit ?? + 20, ); + const dmEnabled = dmConfig?.enabled ?? true; const client = new Client({ intents: [ @@ -111,6 +129,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const isGroupDm = channelType === ChannelType.GroupDM; const isDirectMessage = channelType === ChannelType.DM; const isGuildMessage = Boolean(message.guild); + if (isGroupDm) return; + if (isDirectMessage && !dmEnabled) return; const botId = client.user?.id; const wasMentioned = !isDirectMessage && Boolean(botId && message.mentions.has(botId)); @@ -121,7 +141,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { message.embeds[0]?.description || ""; - if (!isDirectMessage && historyLimit > 0 && baseText) { + if (isGuildMessage && historyLimit > 0 && baseText) { const history = guildHistories.get(message.channelId) ?? []; history.push({ sender: message.member?.displayName ?? message.author.tag, @@ -133,7 +153,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { guildHistories.set(message.channelId, history); } - if (!isDirectMessage && requireMention) { + if (isGuildMessage && requireMention) { if (botId && !wasMentioned) { logger.info( { @@ -146,7 +166,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } } - if (!isDirectMessage && isGuildMessage && guildAllowFrom) { + if (isGuildMessage) { + const channelAllow = normalizeDiscordAllowList(guildChannels, [ + "channel:", + ]); + if (channelAllow) { + const channelName = + "name" in message.channel ? message.channel.name : undefined; + const channelOk = allowListMatches(channelAllow, { + id: message.channelId, + name: channelName, + }); + if (!channelOk) { + logVerbose( + `Blocked discord channel ${message.channelId} not in guild.channels`, + ); + return; + } + } + } + + if (isGuildMessage && guildAllowFrom) { const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [ "guild:", ]); @@ -158,8 +198,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { 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); + !guilds || + allowListMatches(guilds, { + id: guildId, + name: message.guild?.name, + }); + const userOk = + !users || + allowListMatches(users, { + id: userId, + name: message.author.username, + tag: message.author.tag, + }); if (!guildOk || !userOk) { logVerbose( `Blocked discord guild sender ${userId} (guild ${guildId || "unknown"}) not in guildAllowFrom`, @@ -170,22 +220,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; } @@ -300,7 +348,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) { @@ -384,22 +432,67 @@ function buildGuildLabel(message: import("discord.js").Message) { 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 allowAll = cleaned.includes("*"); - const ids = new Set(cleaned.filter((entry) => entry !== "*")); - return { allowAll, ids }; + } + 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); + } + + 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(); +} + +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; + return false; } async function sendTyping(message: Message) { From e85c15d178210ce921829088318bf16f8bf011c1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 10:38:18 +0100 Subject: [PATCH 07/55] docs: note mac app rebuilds need local --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) 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. From 87127fd133eee8c71d7b57d7a067ba402ed739e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 10:40:24 +0100 Subject: [PATCH 08/55] fix: refine web chat session selector --- CHANGELOG.md | 1 + src/canvas-host/a2ui/.bundle.hash | 2 +- ui/src/styles/components.css | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17f4ea6ea..15ff590e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ ### Fixes - Chat UI: keep the chat scrolled to the latest message after switching sessions. - 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. diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index fd929f88d..32350f99b 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -13cc362f2bc44e2a05a6da5e5ba66ea602755f18ed82b18cf244c8044aa84c36 +988ec7bedb11cab74f82faf4475df758e6f07866b69949ffc2cce89cb3d8265b diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 1c459bed3..670ef1d2a 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -214,6 +214,23 @@ outline: none; } +.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 { font-family: var(--mono); min-height: 180px; From 1bf7d2f3bdf28d11e7cb17d9204867c172ffd745 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 10:47:31 +0100 Subject: [PATCH 09/55] docs: update trello skill requirements --- CHANGELOG.md | 1 + skills/trello/SKILL.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5385b7c70..c1273b180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - 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. +- 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. diff --git a/skills/trello/SKILL.md b/skills/trello/SKILL.md index 51c129c84..4ddd421d2 100644 --- a/skills/trello/SKILL.md +++ b/skills/trello/SKILL.md @@ -2,7 +2,7 @@ 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":{"env":["TRELLO_API_KEY","TRELLO_TOKEN"]}}} +metadata: {"clawdis":{"emoji":"📋","requires":{"bins":["jq"],"env":["TRELLO_API_KEY","TRELLO_TOKEN"]}}} --- # Trello Skill @@ -68,7 +68,7 @@ curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&toke - 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: 100 requests per 10 seconds per token +- 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 From 8bd5f1b9f28e08ed6e02e5c320608de482812fb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 10:57:04 +0100 Subject: [PATCH 10/55] fix: improve onboarding allowlist + Control UI link --- CHANGELOG.md | 1 + src/commands/configure.ts | 25 +++++ src/commands/onboard-helpers.ts | 18 ++++ src/commands/onboard-interactive.ts | 29 ++++-- src/commands/onboard-providers.ts | 152 ++++++++++++++++------------ 5 files changed, 150 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be1b71838..2a43e1b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ - 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 `routing.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 codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b 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/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 4e9b98ba5..258ff1575 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -12,6 +12,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"; @@ -205,3 +206,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..39f9c4a53 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"; @@ -481,18 +481,25 @@ export async function runInteractiveOnboarding( note( (() => { - const tailnetIPv4 = pickPrimaryTailnetIPv4(); - const host = - bind === "tailnet" || (bind === "auto" && tailnetIPv4) - ? (tailnetIPv4 ?? "127.0.0.1") - : "127.0.0.1"; - return [ - `Control UI: http://${host}:${port}/`, - `Gateway WS: ws://${host}:${port}`, - ].join("\n"); + const links = resolveControlUiLinks({ bind, port }); + return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].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 }); + await openUrl(links.httpUrl); + } + outro("Onboarding complete."); } diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 1876d8dbe..43c63b44e 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -64,6 +64,93 @@ function noteDiscordTokenHelp(): void { ); } +function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { + return { + ...cfg, + routing: { + ...(cfg.routing ?? {}), + allowFrom, + }, + }; +} + +async function promptWhatsAppAllowFrom( + cfg: ClawdisConfig, + runtime: RuntimeEnv, +): Promise { + const existingAllowFrom = cfg.routing?.allowFrom ?? []; + const existingLabel = + existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; + + note( + [ + "WhatsApp direct chats are gated by `routing.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 setRoutingAllowFrom(cfg, undefined); + if (mode === "any") return setRoutingAllowFrom(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 setRoutingAllowFrom(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")) { From bd3d18f660205fa6bd0ab8d3ed0acb74dd9e5d0e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:02:06 +0100 Subject: [PATCH 11/55] fix: unbreak TypeScript build --- CHANGELOG.md | 1 + src/discord/monitor.ts | 17 ++++------------- src/gateway/hooks-mapping.ts | 18 +++++++++++++++--- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a43e1b0c..efaced221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ - 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. - Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured. +- 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. diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index e6159c8d9..a7ea34e56 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -81,7 +81,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const allowFrom = opts.allowFrom ?? dmConfig?.allowFrom ?? cfg.discord?.allowFrom; const guildAllowFrom = - opts.guildAllowFrom ?? guildConfig?.allowFrom ?? cfg.discord?.guildAllowFrom; + opts.guildAllowFrom ?? + guildConfig?.allowFrom ?? + cfg.discord?.guildAllowFrom; const guildChannels = guildConfig?.channels; const requireMention = opts.requireMention ?? @@ -126,10 +128,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (!message.author) return; const channelType = message.channel.type; - const isGroupDm = channelType === ChannelType.GroupDM; const isDirectMessage = channelType === ChannelType.DM; const isGuildMessage = Boolean(message.guild); - if (isGroupDm) return; if (isDirectMessage && !dmEnabled) return; const botId = client.user?.id; const wasMentioned = @@ -249,9 +249,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const fromLabel = isDirectMessage ? buildDirectLabel(message) - : isGroupDm - ? buildGroupDmLabel(message) - : buildGuildLabel(message); + : buildGuildLabel(message); const groupSubject = (() => { if (isDirectMessage) return undefined; const channelName = @@ -416,13 +414,6 @@ function buildDirectLabel(message: import("discord.js").Message) { return `${username} id:${message.author.id}`; } -function buildGroupDmLabel(message: import("discord.js").Message) { - const channelName = - "name" in message.channel ? message.channel.name : undefined; - const name = channelName ? ` ${channelName}` : ""; - return `Group DM${name} id:${message.channelId}`; -} - function buildGuildLabel(message: import("discord.js").Message) { const channelName = "name" in message.channel ? message.channel.name : message.channelId; diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 1d29d786b..0b2ee8132 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -18,7 +18,13 @@ export type HookMappingResolved = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -50,7 +56,13 @@ export type HookAction = wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram" | "discord"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "signal" + | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -86,7 +98,7 @@ type HookTransformResult = Partial<{ name: string; sessionKey: string; deliver: boolean; - channel: "last" | "whatsapp" | "telegram" | "discord"; + channel: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; to: string; thinking: string; timeoutSeconds: number; From eb44ae76f1d5b92e6f2c2e5b01c85b9a0ee401c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:15:52 +0100 Subject: [PATCH 12/55] feat: add discord guild map + group dm controls --- CHANGELOG.md | 2 + docs/configuration.md | 26 ++-- docs/discord.md | 50 ++++--- docs/session.md | 2 +- src/auto-reply/reply.ts | 8 +- src/auto-reply/templating.ts | 2 + src/config/config.ts | 70 ++++++---- src/config/sessions.ts | 17 ++- src/discord/monitor.ts | 264 +++++++++++++++++++++++++---------- src/gateway/server.ts | 3 - 10 files changed, 303 insertions(+), 141 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efaced221..8d5c765c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 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`. ### Features - Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. @@ -23,6 +24,7 @@ - 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). - 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. diff --git a/docs/configuration.md b/docs/configuration.md index 4c9f7f054..8564e98ce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -177,22 +177,28 @@ Configure the Discord bot by setting the bot token and optional gating: enableReactions: true, // allow agent-triggered reactions dm: { enabled: true, // disable all DMs when false - allowFrom: ["1234567890", "steipete"] // optional DM allowlist (ids or names) + allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names) + groupEnabled: false, // enable group DMs + groupChannels: ["clawd-dm"] // optional group DM allowlist }, - guild: { - channels: ["general", "help"], // optional channel allowlist (ids or names) - allowFrom: { - guilds: ["123456789012345678"], // optional guild allowlist (ids or names) - users: ["987654321098765432"] // optional user allowlist (ids or names) - }, - requireMention: true, // require @bot mentions in guilds - historyLimit: 20 // include last N guild messages as context - } + 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. +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) diff --git a/docs/discord.md b/docs/discord.md index d914cab11..da3e4658c 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -11,8 +11,8 @@ 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 `discord:group:`. -- Group DMs are treated as group sessions (separate from `main`) and show up with a `discord:g-...` display label. +- 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 @@ -21,14 +21,14 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 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`). 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.guild.requireMention = false` (legacy: `discord.requireMention`). -7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Legacy: `discord.allowFrom`. -8. Optional guild allowlist: set `discord.guild.allowFrom` with `guilds` and/or `users` (ids or names) to gate who can invoke the bot in servers. Legacy: `discord.guildAllowFrom`. -9. Optional guild channel allowlist: set `discord.guild.channels` with channel ids or names to restrict where the bot listens. -10. Optional guild context history: set `discord.guild.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable (legacy: `discord.historyLimit`). -11. 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 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. 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 `#`. ## Capabilities & limits - DMs and guild text channels (threads are treated as separate channels; voice not supported). @@ -47,16 +47,20 @@ Note: Discord does not provide a simple username → id lookup without extra gui enableReactions: true, dm: { enabled: true, - allowFrom: ["123456789012345678", "steipete"] + allowFrom: ["123456789012345678", "steipete"], + groupEnabled: false, + groupChannels: ["clawd-dm"] }, - guild: { - channels: ["general", "help"], - allowFrom: { - guilds: ["123456789012345678", "My Server"], - users: ["987654321098765432", "steipete"] - }, - requireMention: true, - historyLimit: 20 + guilds: { + "123456789012345678": { + slug: "friends-of-clawd", + requireMention: false, + users: ["987654321098765432", "steipete"], + channels: { + general: { allow: true }, + help: { allow: true, requireMention: true } + } + } } } } @@ -64,11 +68,15 @@ Note: Discord does not provide a simple username → id lookup without extra gui - `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. -- `guild.allowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids or names). When both are set, both must match. -- `guild.channels`: Optional allowlist for channel ids or names. -- `guild.requireMention`: when `true`, messages in guild channels must mention the bot. +- `dm.groupEnabled`: enable group DMs (default `false`). +- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs. +- `guilds`: per-guild rules keyed by guild id (preferred) or slug. +- `guilds..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). - `mediaMaxMb`: clamp inbound media saved to disk. -- `guild.historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). +- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). - `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`). ## Reactions diff --git a/docs/session.md b/docs/session.md index ca78efed8..8430819fa 100644 --- a/docs/session.md +++ b/docs/session.md @@ -24,7 +24,7 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli ## 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 isolate state with `surface:group:` keys (rooms/channels use `surface:channel:`); 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 diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 17bc067c7..fff9a5158 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -453,14 +453,20 @@ export async function getReplyFromConfig( 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 = - isRoomSurface && subject && subject.startsWith("#") ? subject : undefined; + 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, diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 00dc4a9a7..3e3c0ac59 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -12,6 +12,8 @@ export type MsgContext = { Transcript?: string; ChatType?: string; GroupSubject?: string; + GroupRoom?: string; + GroupSpace?: string; GroupMembers?: string; SenderName?: string; SenderE164?: string; diff --git a/src/config/config.ts b/src/config/config.ts index 7aa8b46d3..b4d1161c7 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -169,42 +169,35 @@ export type DiscordDmConfig = { 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 DiscordGuildConfig = { - /** Allowlist for guild messages (guilds/users by id or name). */ - allowFrom?: { - guilds?: Array; - users?: Array; - }; - /** Allowlist for guild channels (ids or names). */ - channels?: Array; - /** Require @bot mention to respond in guilds. Default: true. */ +export type DiscordGuildChannelConfig = { + allow?: boolean; requireMention?: boolean; - /** Number of recent guild messages to include for context. */ - historyLimit?: number; +}; + +export type DiscordGuildEntry = { + slug?: string; + requireMention?: boolean; + users?: Array; + channels?: Record; }; export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; token?: string; - /** Legacy DM allowlist (ids). Prefer discord.dm.allowFrom. */ - allowFrom?: Array; - /** Legacy guild allowlist (ids). Prefer discord.guild.allowFrom. */ - guildAllowFrom?: { - guilds?: Array; - users?: Array; - }; - /** Legacy mention requirement. Prefer discord.guild.requireMention. */ - requireMention?: boolean; mediaMaxMb?: number; - /** Legacy history limit. Prefer discord.guild.historyLimit. */ historyLimit?: number; /** Allow agent-triggered Discord reactions (default: true). */ enableReactions?: boolean; dm?: DiscordDmConfig; - guild?: DiscordGuildConfig; + /** New per-guild config keyed by guild id or slug. */ + guilds?: Record; }; export type SignalConfig = { @@ -934,14 +927,6 @@ 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 - .object({ - guilds: z.array(z.union([z.string(), z.number()])).optional(), - users: z.array(z.union([z.string(), z.number()])).optional(), - }) - .optional(), - requireMention: z.boolean().optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), enableReactions: z.boolean().optional(), @@ -949,8 +934,33 @@ const ClawdisSchema = z.object({ .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 diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 0a4f7df60..f96de2cf9 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -116,11 +116,13 @@ export function buildGroupDisplayName(params: { 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 = - params.room?.trim() || - params.subject?.trim() || - params.space?.trim() || - ""; + (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); @@ -130,7 +132,12 @@ export function buildGroupDisplayName(params: { if (!params.room && token.startsWith("#")) { token = token.replace(/^#+/, ""); } - if (token && !/^[@#]/.test(token) && !token.startsWith("g-")) { + if ( + token && + !/^[@#]/.test(token) && + !token.startsWith("g-") && + !token.includes("#") + ) { token = `g-${token}`; } return token ? `${surfaceKey}:${token}` : surfaceKey; diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index a7ea34e56..0ac5c3e8b 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -25,12 +25,6 @@ export type MonitorDiscordOpts = { token?: string; runtime?: RuntimeEnv; abortSignal?: AbortSignal; - allowFrom?: Array; - guildAllowFrom?: { - guilds?: Array; - users?: Array; - }; - requireMention?: boolean; mediaMaxMb?: number; historyLimit?: number; }; @@ -54,6 +48,19 @@ type DiscordAllowList = { names: Set; }; +type DiscordGuildEntryResolved = { + id?: string; + slug?: string; + requireMention?: boolean; + users?: Array; + channels?: Record; +}; + +type DiscordChannelConfigResolved = { + allowed: boolean; + requireMention?: boolean; +}; + export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = loadConfig(); const token = normalizeDiscordToken( @@ -77,29 +84,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }; const dmConfig = cfg.discord?.dm; - const guildConfig = cfg.discord?.guild; - const allowFrom = - opts.allowFrom ?? dmConfig?.allowFrom ?? cfg.discord?.allowFrom; - const guildAllowFrom = - opts.guildAllowFrom ?? - guildConfig?.allowFrom ?? - cfg.discord?.guildAllowFrom; - const guildChannels = guildConfig?.channels; - const requireMention = - opts.requireMention ?? - guildConfig?.requireMention ?? - cfg.discord?.requireMention ?? - true; + const guildEntries = cfg.discord?.guilds; + const allowFrom = dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const historyLimit = Math.max( 0, - opts.historyLimit ?? - guildConfig?.historyLimit ?? - cfg.discord?.historyLimit ?? - 20, + opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, ); const dmEnabled = dmConfig?.enabled ?? true; + const groupDmEnabled = dmConfig?.groupEnabled ?? false; + const groupDmChannels = dmConfig?.groupChannels; const client = new Client({ intents: [ @@ -128,8 +123,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (!message.author) return; const channelType = message.channel.type; + 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 = @@ -141,6 +138,58 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { message.embeds[0]?.description || ""; + 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({ @@ -153,7 +202,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { guildHistories.set(message.channelId, history); } - if (isGuildMessage && requireMention) { + const resolvedRequireMention = + channelConfig?.requireMention ?? guildInfo?.requireMention ?? true; + if (isGuildMessage && resolvedRequireMention) { if (botId && !wasMentioned) { logger.info( { @@ -167,56 +218,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } if (isGuildMessage) { - const channelAllow = normalizeDiscordAllowList(guildChannels, [ - "channel:", - ]); - if (channelAllow) { - const channelName = - "name" in message.channel ? message.channel.name : undefined; - const channelOk = allowListMatches(channelAllow, { - id: message.channelId, - name: channelName, - }); - if (!channelOk) { - logVerbose( - `Blocked discord channel ${message.channelId} not in guild.channels`, - ); - return; - } - } - } - - if (isGuildMessage && 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 || - allowListMatches(guilds, { - id: guildId, - name: message.guild?.name, - }); + const userAllow = guildInfo?.users; + if (Array.isArray(userAllow) && userAllow.length > 0) { + const users = normalizeDiscordAllowList(userAllow, [ + "discord:", + "user:", + ]); const userOk = !users || allowListMatches(users, { - id: userId, + id: message.author.id, name: message.author.username, tag: message.author.tag, }); - if (!guildOk || !userOk) { + 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; } } + } if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) { @@ -250,13 +272,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const fromLabel = isDirectMessage ? buildDirectLabel(message) : buildGuildLabel(message); - const groupSubject = (() => { - if (isDirectMessage) return undefined; - const channelName = - "name" in message.channel ? message.channel.name : message.channelId; - if (!channelName) return undefined; - return isGuildMessage ? `#${channelName}` : channelName; - })(); + 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", @@ -298,6 +316,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ChatType: isDirectMessage ? "direct" : "group", SenderName: message.member?.displayName ?? message.author.tag, GroupSubject: groupSubject, + GroupRoom: groupRoom, + GroupSpace: isGuildMessage ? guildSlug || undefined : undefined, Surface: "discord" as const, WasMentioned: wasMentioned, MessageSid: message.id, @@ -457,6 +477,8 @@ function normalizeDiscordAllowList( } 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; @@ -468,6 +490,17 @@ function normalizeDiscordName(value?: string | null) { return value.trim().toLowerCase(); } +function normalizeDiscordSlug(value?: string | null) { + if (!value) return ""; + let text = value.trim().toLowerCase(); + if (!text) return ""; + text = text.replace(/^[@#]+/, ""); + text = text.replace(/[\s_]+/g, "-"); + text = text.replace(/[^a-z0-9-]+/g, "-"); + text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); + return text; +} + function allowListMatches( allowList: DiscordAllowList, candidates: { @@ -483,9 +516,100 @@ function allowListMatches( 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; } +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; +} + +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 }; +} + +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 sendTyping(message: Message) { try { const channel = message.channel; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index e62841b34..73c11f5be 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -2210,9 +2210,6 @@ export async function startGatewayServer( token: discordToken.trim(), runtime: discordRuntimeEnv, abortSignal: discordAbort.signal, - allowFrom: cfg.discord?.allowFrom, - guildAllowFrom: cfg.discord?.guildAllowFrom, - requireMention: cfg.discord?.requireMention, mediaMaxMb: cfg.discord?.mediaMaxMb, historyLimit: cfg.discord?.historyLimit, }) From 4267a1b87d76675fb2212a6ff3740257a756bb3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:19:10 +0100 Subject: [PATCH 13/55] test: cover discord config + slug routing --- src/config/config.test.ts | 59 ++++++++++++++ src/config/sessions.test.ts | 13 ++++ src/discord/monitor.test.ts | 149 ++++++++++++++++++++++++++++++++++++ src/discord/monitor.ts | 18 ++--- 4 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 src/discord/monitor.test.ts diff --git a/src/config/config.test.ts b/src/config/config.test.ts index d007254a9..1f6ac93bb 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -206,6 +206,65 @@ describe("config identity defaults", () => { }); }); +import fs from "node:fs/promises"; +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 () => { diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index afec54a51..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, @@ -51,6 +52,18 @@ describe("sessions", () => { ).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/discord/monitor.test.ts b/src/discord/monitor.test.ts new file mode 100644 index 000000000..e3d6dee50 --- /dev/null +++ b/src/discord/monitor.test.ts @@ -0,0 +1,149 @@ +import { + allowListMatches, + normalizeDiscordAllowList, + normalizeDiscordSlug, + resolveDiscordChannelConfig, + resolveDiscordGuildEntry, + resolveGroupDmAllow, + type DiscordGuildEntryResolved, +} 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(); + 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 0ac5c3e8b..904e2755c 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -42,13 +42,13 @@ type DiscordHistoryEntry = { messageId?: string; }; -type DiscordAllowList = { +export type DiscordAllowList = { allowAll: boolean; ids: Set; names: Set; }; -type DiscordGuildEntryResolved = { +export type DiscordGuildEntryResolved = { id?: string; slug?: string; requireMention?: boolean; @@ -56,7 +56,7 @@ type DiscordGuildEntryResolved = { channels?: Record; }; -type DiscordChannelConfigResolved = { +export type DiscordChannelConfigResolved = { allowed: boolean; requireMention?: boolean; }; @@ -440,7 +440,7 @@ 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[], ): DiscordAllowList | null { @@ -490,7 +490,7 @@ function normalizeDiscordName(value?: string | null) { return value.trim().toLowerCase(); } -function normalizeDiscordSlug(value?: string | null) { +export function normalizeDiscordSlug(value?: string | null) { if (!value) return ""; let text = value.trim().toLowerCase(); if (!text) return ""; @@ -501,7 +501,7 @@ function normalizeDiscordSlug(value?: string | null) { return text; } -function allowListMatches( +export function allowListMatches( allowList: DiscordAllowList, candidates: { id?: string; @@ -523,7 +523,7 @@ function allowListMatches( return false; } -function resolveDiscordGuildEntry(params: { +export function resolveDiscordGuildEntry(params: { guild: import("discord.js").Guild | null; guildEntries: Record | undefined; }): DiscordGuildEntryResolved | null { @@ -570,7 +570,7 @@ function resolveDiscordGuildEntry(params: { return null; } -function resolveDiscordChannelConfig(params: { +export function resolveDiscordChannelConfig(params: { guildInfo: DiscordGuildEntryResolved | null; channelId: string; channelName?: string; @@ -594,7 +594,7 @@ function resolveDiscordChannelConfig(params: { return { allowed: true }; } -function resolveGroupDmAllow(params: { +export function resolveGroupDmAllow(params: { channels: Array | undefined; channelId: string; channelName?: string; From 30b5955f22857af08b2651a5df616065e3a6c0e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:26:09 +0100 Subject: [PATCH 14/55] fix(discord): add tag/id to from label --- CHANGELOG.md | 1 + src/discord/monitor.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5385b7c70..4b314ced8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ - 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. - 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). - 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. diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 68be47c0e..42c459aae 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -224,7 +224,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 ? `${message.member.displayName} - ${message.member.user.id}` : `${message.author.username} - ${message.author.id}`}]`; + const name = message.author.tag; + const id = message.author.id; + combinedBody = `${combinedBody}\n[from: ${name} id:${id}]`; shouldClearHistory = true; } From 2d7289bcad945eaf271421b27a88f26b4698bd1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:29:35 +0100 Subject: [PATCH 15/55] docs: update changelog for cron fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 541a99a78..c3a02d0e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,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. From 25762c0ac69c01152e15e66db9e1fe1cbbaac200 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:32:59 +0100 Subject: [PATCH 16/55] docs(discord): note from label includes tag/id --- docs/discord.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/discord.md b/docs/discord.md index da3e4658c..449a479ce 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -29,6 +29,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 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). From eda74d3a55c39f149c4b277f95b12112f9ce048b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:33:49 +0100 Subject: [PATCH 17/55] test: cover every schedule anchor boundary --- src/cron/schedule.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index f38bc6a2d..a40a698bd 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -31,4 +31,13 @@ describe("cron schedule", () => { // 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); + }); }); From fa16304e4f027cf2fdb5ac8f1c3818c4a4739a4b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:54:30 +0100 Subject: [PATCH 18/55] docs: note discord ignore-list removal --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a02d0e8..952eea68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - 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. From b50df6eb1d0b3df82be451130c205229963a7264 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 12:20:38 +0100 Subject: [PATCH 19/55] style: format linted files --- src/auto-reply/reply.ts | 7 ++---- src/commands/status.ts | 5 ++++- src/config/sessions.ts | 15 ++++++++++--- src/discord/monitor.ts | 9 ++++---- src/web/media.test.ts | 49 ++++++++++++++++++++++++++++++----------- 5 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index fff9a5158..58e9eb914 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -27,9 +27,9 @@ import { } from "../agents/workspace.js"; import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { + buildGroupDisplayName, DEFAULT_IDLE_MINUTES, DEFAULT_RESET_TRIGGERS, - buildGroupDisplayName, loadSessionStore, resolveGroupSessionKey, resolveSessionKey, @@ -401,10 +401,7 @@ export async function getReplyFromConfig( sessionKey = resolveSessionKey(sessionScope, ctx, mainKey); sessionStore = loadSessionStore(storePath); - if ( - groupResolution?.legacyKey && - groupResolution.legacyKey !== sessionKey - ) { + if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) { const legacyEntry = sessionStore[groupResolution.legacyKey]; if (legacyEntry && !sessionStore[sessionKey]) { sessionStore[sessionKey] = legacyEntry; diff --git a/src/commands/status.ts b/src/commands/status.ts index 37462379f..ddbb45914 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -169,7 +169,10 @@ const formatContextUsage = ( return `tokens: ${formatKTokens(used)} used, ${formatKTokens(left)} left of ${formatKTokens(contextTokens)} (${pctLabel})`; }; -const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] => { +const classifyKey = ( + key: string, + entry?: SessionEntry, +): SessionStatus["kind"] => { if (key === "global") return "global"; if (key === "unknown") return "unknown"; if (entry?.chatType === "group" || entry?.chatType === "room") return "group"; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index f96de2cf9..4768fd368 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -143,7 +143,9 @@ export function buildGroupDisplayName(params: { return token ? `${surfaceKey}:${token}` : surfaceKey; } -export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null { +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(); @@ -157,7 +159,9 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu const surfaceHint = ctx.Surface?.trim().toLowerCase(); const hasLegacyGroupPrefix = from.startsWith("group:"); - const raw = (hasLegacyGroupPrefix ? from.slice("group:".length) : from).trim(); + const raw = ( + hasLegacyGroupPrefix ? from.slice("group:".length) : from + ).trim(); let surface: string | undefined; let kind: "group" | "channel" | undefined; @@ -209,7 +213,12 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu const resolvedSurface = surface ?? surfaceHint; if (!resolvedSurface) { const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`; - return { key: legacy, id: raw || from, legacyKey: legacy, chatType: "group" }; + return { + key: legacy, + id: raw || from, + legacyKey: legacy, + chatType: "group", + }; } const resolvedKind = kind === "channel" ? "channel" : "group"; diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 08b376597..59b23cf32 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -238,7 +238,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { return; } } - } if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) { @@ -584,14 +583,16 @@ export function resolveDiscordChannelConfig(params: { const entry = channelEntries[channelId] ?? (channelSlug - ? channelEntries[channelSlug] ?? - channelEntries[`#${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: entry.allow !== false, + requireMention: entry.requireMention, + }; } return { allowed: true }; } 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); From fd4cff06caf4d7f2720ef5abbd8bb4d72f6583d7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 12:20:43 +0100 Subject: [PATCH 20/55] test: fix discord/config test lint --- src/config/config.test.ts | 1 - src/discord/monitor.test.ts | 25 +++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 1f6ac93bb..06b774fe7 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -206,7 +206,6 @@ describe("config identity defaults", () => { }); }); -import fs from "node:fs/promises"; describe("config discord", () => { let previousHome: string | undefined; diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index e3d6dee50..5d1d087d5 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -1,15 +1,16 @@ +import { describe, expect, it } from "vitest"; import { allowListMatches, + type DiscordGuildEntryResolved, normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordChannelConfig, resolveDiscordGuildEntry, resolveGroupDmAllow, - type DiscordGuildEntryResolved, } from "./monitor.js"; const fakeGuild = (id: string, name: string) => - ({ id, name } as unknown as import("discord.js").Guild); + ({ id, name }) as unknown as import("discord.js").Guild; const makeEntries = ( entries: Record>, @@ -28,12 +29,9 @@ const makeEntries = ( 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"); + 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", () => { @@ -42,10 +40,13 @@ describe("discord allowlist helpers", () => { ["discord:", "user:", "guild:", "channel:"], ); expect(allow).not.toBeNull(); - 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); + 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); }); }); From eaacebeeccf424b25afc67592d04b2aa3db38b19 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 12:20:48 +0100 Subject: [PATCH 21/55] fix: improve onboarding/imessage errors --- src/commands/onboard-helpers.ts | 10 +++++++++- src/commands/onboard-providers.ts | 2 +- src/imessage/client.ts | 5 +++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 258ff1575..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"; @@ -196,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") diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 43c63b44e..93cdd3a97 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -68,7 +68,7 @@ function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { return { ...cfg, routing: { - ...(cfg.routing ?? {}), + ...cfg.routing, allowFrom, }, }; 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; } From 7f8af736dde604139d866edc9725136e1beaaaaf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:21:40 +0000 Subject: [PATCH 22/55] chore(canvas): regenerate a2ui bundle hash --- src/canvas-host/a2ui/.bundle.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 32350f99b..ace1fb6ed 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -988ec7bedb11cab74f82faf4475df758e6f07866b69949ffc2cce89cb3d8265b +969df6da368b3a802bf0f7f34bf2e30102ae51d91daf45f1fb9328877e2fb335 From 95f03d63ad2288fd0ff83c632f7af0cdab5329d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:21:51 +0000 Subject: [PATCH 23/55] style(ui): refresh dashboard theme --- ui/src/styles/base.css | 143 +++++++++++++++++++----------- ui/src/styles/components.css | 167 ++++++++++++++++++++++------------- ui/src/styles/layout.css | 112 +++++++++++++++++------ 3 files changed, 280 insertions(+), 142 deletions(-) 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 670ef1d2a..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,28 @@ .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 { @@ -238,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; @@ -249,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); } @@ -259,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 { @@ -274,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; @@ -308,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; @@ -333,7 +380,7 @@ } .chip { - font-size: 12px; + font-size: 11px; border: 1px solid var(--border); border-radius: 999px; padding: 4px 8px; @@ -365,7 +412,7 @@ } .table-head { - font-size: 12px; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--muted); @@ -414,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% ); } @@ -457,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 { @@ -469,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% ); } @@ -496,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; } @@ -548,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); @@ -579,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, From 506b66a852bc5cc4c30ec4d946b95e1ac10fca1e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:22:05 +0000 Subject: [PATCH 24/55] docs: add FAQ with common questions from Discord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers: - Installation & setup (data locations, unauthorized errors, fresh start, doctor) - Migration & deployment (new machine, VPS, Docker) - Multi-instance & contexts (one Clawd philosophy, groups for separation) - Context & memory (200k tokens, autocompaction, workspace location) - Platforms (supported platforms, multi-platform, WhatsApp numbers) - Troubleshooting (build errors, WhatsApp logout, gateway issues) - Chat commands reference Based on community questions from #help channel. 🦞 --- docs/faq.md | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + 2 files changed, 254 insertions(+) create mode 100644 docs/faq.md diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 000000000..082303087 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,253 @@ +--- +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`). + +### 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. + +--- + +## 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) + +--- + +## 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/index.md b/docs/index.md index 160725093..3f454b5be 100644 --- a/docs/index.md +++ b/docs/index.md @@ -116,6 +116,7 @@ Example: ## Docs - Start here: + - [FAQ](./faq.md) ← *common questions answered* - [Configuration](./configuration.md) - [Nix mode](./nix.md) - [Clawd personal assistant setup](./clawd.md) From d656db4d04a570187579373b737c4da0f9e5c445 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 12:23:16 +0100 Subject: [PATCH 25/55] fix: widen discord channel type check --- src/discord/monitor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 59b23cf32..78c5708ea 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -122,7 +122,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (message.author?.bot) return; if (!message.author) return; - const channelType = message.channel.type; + // 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); From 21a64a9957e934cf967b8af5786dc4cf8837b55f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:24:41 +0000 Subject: [PATCH 26/55] docs: link FAQ and add platforms note --- README.md | 2 +- docs/faq.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a8cb7900b..538adfb2d 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**. diff --git a/docs/faq.md b/docs/faq.md index 082303087..efec33c64 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -20,6 +20,14 @@ Everything lives under `~/.clawdis/`: 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, Windows, and Linux!** Anywhere Node.js 22+ runs. The onboarding wizard (`clawdis onboard`) works on all three. + +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: From a53cdbf1b4d93cef7a5296e66abaec6765f29422 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:30:27 +0000 Subject: [PATCH 27/55] docs: clarify Windows is untested in FAQ --- docs/faq.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/faq.md b/docs/faq.md index efec33c64..74e129c53 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -22,7 +22,11 @@ Your **workspace** (AGENTS.md, memory files, skills) is separate — configured ### What platforms does Clawdis run on? -**macOS, Windows, and Linux!** Anywhere Node.js 22+ runs. The onboarding wizard (`clawdis onboard`) works on all three. +**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) From e92b4806293637ccc2dabd7437722279549cbb7d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:40:55 +0000 Subject: [PATCH 28/55] fix(signal): surface signal-cli failures as errors --- src/signal/daemon.test.ts | 9 +++++++++ src/signal/daemon.ts | 2 ++ 2 files changed, 11 insertions(+) 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"; } From 2b3ddabe9055cf8e7dcbc4a8e332cc4befb62126 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:41:01 +0000 Subject: [PATCH 29/55] fix(gateway): gate providers by config presence --- src/commands/onboard-providers.ts | 20 +++++++- src/gateway/server.ts | 78 +++++++++++++++++++++++++++---- src/infra/provider-summary.ts | 22 ++++++--- 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 93cdd3a97..a120d66d9 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -301,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({ @@ -368,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/gateway/server.ts b/src/gateway/server.ts index 73c11f5be..6cebf0577 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -2074,6 +2074,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, @@ -2168,6 +2177,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, @@ -2270,6 +2288,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}`; @@ -4345,21 +4383,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" @@ -4368,7 +4418,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(); } @@ -4380,7 +4430,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) { @@ -4422,7 +4482,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, @@ -4433,7 +4493,7 @@ export async function startGatewayServer( lastProbeAt, }, discord: { - configured: Boolean(discordToken), + configured: discordEnabled && Boolean(discordToken), tokenSource: discordTokenSource, running: discordRuntime.running, lastStartAt: discordRuntime.lastStartAt ?? null, diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index 70186d4ef..2645a7fb8 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") From 6bad75827a205462bf863cbb33062c11d124d728 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:41:08 +0000 Subject: [PATCH 30/55] docs: clarify Signal setup and env-token gating --- CHANGELOG.md | 2 ++ docs/configuration.md | 4 ++-- docs/discord.md | 3 ++- docs/signal.md | 18 ++++++++++++++++++ docs/telegram.md | 3 ++- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 952eea68d..067ff1fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - 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`. ### Features - Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. @@ -48,6 +49,7 @@ - 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. diff --git a/docs/configuration.md b/docs/configuration.md index 8564e98ce..0128706b4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 @@ -197,7 +197,7 @@ Configure the Discord bot by setting the bot token and optional gating: } ``` -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) diff --git a/docs/discord.md b/docs/discord.md index 449a479ce..f482588e3 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -19,7 +19,8 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 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 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. diff --git a/docs/signal.md b/docs/signal.md index b386f9d6b..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). diff --git a/docs/telegram.md b/docs/telegram.md index 06f298083..02f63d5b0 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -17,7 +17,8 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup ## 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. From 58d32d4542acfba2569144903192b44736742fe4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 11:50:09 +0000 Subject: [PATCH 31/55] docs: expand FAQ with skills, Tailscale, troubleshooting - How to add/reload skills (/reset) - Tailscale for multi-machine setups - Using Codex to debug - Handling supervised processes on Linux - Clean uninstall steps --- docs/faq.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/faq.md b/docs/faq.md index 74e129c53..815c27198 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -213,6 +213,29 @@ One WhatsApp account = one phone number = one gateway connection. For a second n --- +## 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) @@ -246,6 +269,48 @@ Common issues: - 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 From 0766c5e3cbde4b7105a7c39ca7c07521440725d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 12:59:47 +0100 Subject: [PATCH 32/55] refactor: move whatsapp allowFrom config --- CHANGELOG.md | 3 +- README.md | 4 +- docs/agent.md | 2 +- docs/clawd.md | 8 ++- docs/configuration.md | 10 +-- docs/doctor.md | 36 ++++++++++ docs/group-messages.md | 4 +- docs/groups.md | 2 +- docs/health.md | 2 +- docs/index.md | 8 +-- docs/security.md | 2 +- docs/telegram.md | 2 +- docs/troubleshooting.md | 4 +- docs/whatsapp.md | 8 +-- src/auto-reply/reply.directive.test.ts | 14 ++-- src/auto-reply/reply.triggers.test.ts | 10 +-- src/auto-reply/reply.ts | 40 +++++------ src/cli/program.ts | 16 +++++ src/commands/agent.ts | 4 +- src/commands/doctor.test.ts | 96 ++++++++++++++++++++++++++ src/commands/doctor.ts | 89 +++++++++++++++++++++++- src/commands/onboard-providers.ts | 16 ++--- src/config/config.test.ts | 34 +++++++++ src/config/config.ts | 70 ++++++++++++++++++- src/cron/isolated-agent.ts | 2 +- src/gateway/server.test.ts | 5 +- src/gateway/server.ts | 2 +- src/imessage/monitor.ts | 3 +- src/infra/heartbeat-runner.test.ts | 6 +- src/infra/heartbeat-runner.ts | 4 +- src/infra/provider-summary.ts | 4 +- src/signal/monitor.ts | 3 +- src/utils.ts | 2 +- src/web/auto-reply.test.ts | 18 ++--- src/web/auto-reply.ts | 8 +-- src/web/inbound.media.test.ts | 2 +- src/web/inbound.ts | 2 +- src/web/monitor-inbox.test.ts | 22 +++--- src/web/test-helpers.ts | 2 +- 39 files changed, 452 insertions(+), 117 deletions(-) create mode 100644 docs/doctor.md create mode 100644 src/commands/doctor.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 067ff1fb6..58c4cb842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 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. @@ -61,7 +62,7 @@ - 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 `routing.allowFrom` and print (optionally open) the Control UI URL when done. +- 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 codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b diff --git a/README.md b/README.md index 538adfb2d..102bb644c 100644 --- a/README.md +++ b/README.md @@ -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 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 0128706b4..38bee9cbf 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"] } } ``` 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/group-messages.md b/docs/group-messages.md index 94012b168..e8fb355d0 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -9,7 +9,7 @@ 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. +- 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. @@ -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. diff --git a/docs/groups.md b/docs/groups.md index febde1f18..b24b27e39 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -40,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 3f454b5be..dd9316fb5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -100,16 +100,14 @@ 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"] } } } ``` 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/telegram.md b/docs/telegram.md index 02f63d5b0..797783f72 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -25,7 +25,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - 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 `telegram:group:` and require mention/command to trigger replies. -6) Optional allowlist: reuse `routing.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). +6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). ## Capabilities & limits (Bot API) - 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 ee4a32837..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. @@ -57,7 +57,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. - `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/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 3d84067d7..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") }, @@ -283,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") }, @@ -324,7 +326,7 @@ describe("trigger handling", () => { model: "anthropic/claude-opus-4-5", workspace: join(home, "clawd"), }, - routing: { + whatsapp: { allowFrom: ["*"], }, session: { @@ -363,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 58e9eb914..b3c380a38 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -841,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 = @@ -862,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)); @@ -876,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(); diff --git a/src/cli/program.ts b/src/cli/program.ts index b53f4137d..d5e9d6a52 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. Ask your agent to run \"clawdis doctor\" 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 64daf9a70..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); diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts new file mode 100644 index 000000000..a988a370d --- /dev/null +++ b/src/commands/doctor.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; + +const readConfigFileSnapshot = vi.fn(); +const writeConfigFile = vi.fn().mockResolvedValue(undefined); +const validateConfigObject = vi.fn((raw: unknown) => ({ + ok: true as const, + config: raw as Record, +})); + +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, + validateConfigObject, +})); + +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(), + }; + + 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..da8072a53 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -5,6 +5,7 @@ import type { ClawdisConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDIS, readConfigFileSnapshot, + validateConfigObject, writeConfigFile, } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; @@ -19,6 +20,65 @@ import { printWizardHeader, } from "./onboard-helpers.js"; +type LegacyMigration = { + id: string; + describe: string; + apply: (raw: Record, changes: string[]) => void; +}; + +const LEGACY_MIGRATIONS: LegacyMigration[] = [ + // Legacy migration (2026-01-02, commit: TBD) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. + { + 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 applyLegacyMigrations(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_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 resolveMode(cfg: ClawdisConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } @@ -29,10 +89,37 @@ 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) { + const { config: migrated, changes } = applyLegacyMigrations( + snapshot.parsed, + ); + if (changes.length > 0) { + note(changes.join("\n"), "Doctor changes"); + } + if (migrated) { + cfg = migrated; + } + } + } + const workspaceDir = resolveUserPath( cfg.agent?.workspace ?? DEFAULT_WORKSPACE, ); diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index a120d66d9..af537ec06 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -64,11 +64,11 @@ function noteDiscordTokenHelp(): void { ); } -function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { +function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { return { ...cfg, - routing: { - ...cfg.routing, + whatsapp: { + ...cfg.whatsapp, allowFrom, }, }; @@ -78,13 +78,13 @@ async function promptWhatsAppAllowFrom( cfg: ClawdisConfig, runtime: RuntimeEnv, ): Promise { - const existingAllowFrom = cfg.routing?.allowFrom ?? []; + const existingAllowFrom = cfg.whatsapp?.allowFrom ?? []; const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; note( [ - "WhatsApp direct chats are gated by `routing.allowFrom`.", + "WhatsApp direct chats are gated by `whatsapp.allowFrom`.", 'Default (unset) = self-chat only; use "*" to allow anyone.', `Current: ${existingLabel}`, ].join("\n"), @@ -114,8 +114,8 @@ async function promptWhatsAppAllowFrom( ) as (typeof options)[number]["value"]; if (mode === "keep") return cfg; - if (mode === "self") return setRoutingAllowFrom(cfg, undefined); - if (mode === "any") return setRoutingAllowFrom(cfg, ["*"]); + if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined); + if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]); const allowRaw = guardCancel( await text({ @@ -148,7 +148,7 @@ async function promptWhatsAppAllowFrom( part === "*" ? "*" : normalizeE164(part), ); const unique = [...new Set(normalized.filter(Boolean))]; - return setRoutingAllowFrom(cfg, unique); + return setWhatsAppAllowFrom(cfg, unique); } export async function setupProviders( diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 06b774fe7..06e9e8377 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -488,3 +488,37 @@ 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("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 b4d1161c7..9c217fc95 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 */ @@ -260,7 +265,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[]; @@ -525,6 +529,7 @@ export type ClawdisConfig = { messages?: MessagesConfig; session?: SessionConfig; web?: WebConfig; + whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; signal?: SignalConfig; @@ -693,7 +698,6 @@ const HeartbeatSchema = z const RoutingSchema = z .object({ - allowFrom: z.array(z.string()).optional(), groupChat: GroupChatSchema, transcribeAudio: TranscribeAudioSchema, queue: z @@ -909,6 +913,11 @@ const ClawdisSchema = z.object({ .optional(), }) .optional(), + whatsapp: z + .object({ + allowFrom: z.array(z.string()).optional(), + }) + .optional(), telegram: z .object({ enabled: z.boolean().optional(), @@ -1131,6 +1140,11 @@ export type ConfigValidationIssue = { message: string; }; +export type LegacyConfigIssue = { + path: string; + message: string; +}; + export type ConfigFileSnapshot = { path: string; exists: boolean; @@ -1139,8 +1153,42 @@ export type ConfigFileSnapshot = { valid: boolean; config: ClawdisConfig; issues: ConfigValidationIssue[]; + legacyIssues: LegacyConfigIssue[]; }; +type LegacyConfigRule = { + path: string[]; + message: string; +}; + +const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ + { + path: ["routing", "allowFrom"], + message: + "routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).", + }, +]; + +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; +} + function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -1199,6 +1247,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 { @@ -1271,6 +1329,7 @@ export async function readConfigFileSnapshot(): Promise { const exists = fs.existsSync(configPath); if (!exists) { const config = applyTalkApiKey({}); + const legacyIssues: LegacyConfigIssue[] = []; return { path: configPath, exists: false, @@ -1279,6 +1338,7 @@ export async function readConfigFileSnapshot(): Promise { valid: true, config, issues: [], + legacyIssues, }; } @@ -1296,9 +1356,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 { @@ -1309,6 +1372,7 @@ export async function readConfigFileSnapshot(): Promise { valid: false, config: {}, issues: validated.issues, + legacyIssues, }; } @@ -1320,6 +1384,7 @@ export async function readConfigFileSnapshot(): Promise { valid: true, config: applyTalkApiKey(validated.config), issues: [], + legacyIssues, }; } catch (err) { return { @@ -1330,6 +1395,7 @@ export async function readConfigFileSnapshot(): Promise { valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], + legacyIssues: [], }; } } 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/gateway/server.test.ts b/src/gateway/server.test.ts index cfac4af86..2c58d039f 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -163,6 +163,7 @@ vi.mock("../config/config.js", () => { valid: true, config: {}, issues: [], + legacyIssues: [], }; } try { @@ -176,6 +177,7 @@ vi.mock("../config/config.js", () => { valid: true, config: parsed, issues: [], + legacyIssues: [], }; } catch (err) { return { @@ -186,6 +188,7 @@ vi.mock("../config/config.js", () => { valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], + legacyIssues: [], }; } }; @@ -206,7 +209,7 @@ vi.mock("../config/config.js", () => { model: "anthropic/claude-opus-4-5", workspace: path.join(os.tmpdir(), "clawd-gateway-test"), }, - routing: { + whatsapp: { allowFrom: testAllowFrom, }, session: { mainKey: "main", store: testSessionStorePath }, diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6cebf0577..979e04188 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -6641,7 +6641,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/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/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 2645a7fb8..e471f0118 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -80,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/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/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 05b217e7b..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" }, @@ -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 762cb8135..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( @@ -448,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))]; @@ -918,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/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: ["*"], }, From b9b862a3802d74be51945eb82ce09b07cd17d36d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 12:59:56 +0100 Subject: [PATCH 33/55] chore: note doctor migration commit --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index da8072a53..607b3870a 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -27,7 +27,7 @@ type LegacyMigration = { }; const LEGACY_MIGRATIONS: LegacyMigration[] = [ - // Legacy migration (2026-01-02, commit: TBD) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. + // Legacy migration (2026-01-02, commit: 3c6b59d8) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. { id: "routing.allowFrom->whatsapp.allowFrom", describe: "Move routing.allowFrom to whatsapp.allowFrom", From 55665246bb24131556dbe3d171c3bfa3693ed9a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:00:44 +0100 Subject: [PATCH 34/55] chore: refresh doctor migration commit --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 607b3870a..1758dff0b 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -27,7 +27,7 @@ type LegacyMigration = { }; const LEGACY_MIGRATIONS: LegacyMigration[] = [ - // Legacy migration (2026-01-02, commit: 3c6b59d8) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. + // Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. { id: "routing.allowFrom->whatsapp.allowFrom", describe: "Move routing.allowFrom to whatsapp.allowFrom", From 16420e5b31f08f2b7848bff5da463b915a9f9934 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:07:14 +0100 Subject: [PATCH 35/55] refactor: auto-migrate legacy config in gateway --- src/cli/program.ts | 2 +- src/commands/doctor.test.ts | 11 +++-- src/commands/doctor.ts | 80 +++++++------------------------------ src/config/config.test.ts | 11 +++++ src/config/config.ts | 58 +++++++++++++++++++++++++++ src/gateway/server.test.ts | 4 ++ src/gateway/server.ts | 26 ++++++++++++ 7 files changed, 123 insertions(+), 69 deletions(-) diff --git a/src/cli/program.ts b/src/cli/program.ts index d5e9d6a52..926094987 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -79,7 +79,7 @@ export function buildProgram() { .join("\n"); defaultRuntime.error( danger( - `Legacy config entries detected. Ask your agent to run \"clawdis doctor\" to migrate.\n${issues}`, + `Legacy config entries detected. Run \"clawdis doctor\" (or ask your agent) to migrate.\n${issues}`, ), ); process.exit(1); diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index a988a370d..000e6c01c 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi } from "vitest"; const readConfigFileSnapshot = vi.fn(); const writeConfigFile = vi.fn().mockResolvedValue(undefined); -const validateConfigObject = vi.fn((raw: unknown) => ({ - ok: true as const, +const migrateLegacyConfig = vi.fn((raw: unknown) => ({ config: raw as Record, + changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], })); vi.mock("@clack/prompts", () => ({ @@ -22,7 +22,7 @@ vi.mock("../config/config.js", () => ({ CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json", readConfigFileSnapshot, writeConfigFile, - validateConfigObject, + migrateLegacyConfig, })); vi.mock("../runtime.js", () => ({ @@ -81,6 +81,11 @@ describe("doctor", () => { exit: vi.fn(), }; + migrateLegacyConfig.mockReturnValue({ + config: { whatsapp: { allowFrom: ["+15555550123"] } }, + changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], + }); + await doctorCommand(runtime); expect(writeConfigFile).toHaveBeenCalledTimes(1); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 1758dff0b..ac35fbe30 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,8 +4,8 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdisConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDIS, + migrateLegacyConfig, readConfigFileSnapshot, - validateConfigObject, writeConfigFile, } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; @@ -20,65 +20,6 @@ import { printWizardHeader, } from "./onboard-helpers.js"; -type LegacyMigration = { - id: string; - describe: string; - apply: (raw: Record, changes: string[]) => void; -}; - -const LEGACY_MIGRATIONS: LegacyMigration[] = [ - // Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. - { - 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 applyLegacyMigrations(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_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 resolveMode(cfg: ClawdisConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } @@ -108,9 +49,8 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { runtime, ); if (migrate) { - const { config: migrated, changes } = applyLegacyMigrations( - snapshot.parsed, - ); + // Legacy migration (2026-01-02, commit: 0766c5e3) — 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"); } @@ -144,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) { @@ -166,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/config/config.test.ts b/src/config/config.test.ts index 06e9e8377..4d46782fe 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -502,6 +502,17 @@ describe("legacy config detection", () => { } }); + 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"); diff --git a/src/config/config.ts b/src/config/config.ts index 9c217fc95..2ea0300bf 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1161,6 +1161,12 @@ type LegacyConfigRule = { message: string; }; +type LegacyConfigMigration = { + id: string; + describe: string; + apply: (raw: Record, changes: string[]) => void; +}; + const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ { path: ["routing", "allowFrom"], @@ -1169,6 +1175,37 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ }, ]; +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; @@ -1189,6 +1226,27 @@ function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { 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, "\\$&"); } diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 2c58d039f..f9e059693 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -204,6 +204,10 @@ vi.mock("../config/config.js", () => { CONFIG_PATH_CLAWDIS: resolveConfigPath(), STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), isNixMode: false, + migrateLegacyConfig: (raw: unknown) => ({ + config: raw as Record, + changes: [], + }), loadConfig: () => ({ agent: { model: "anthropic/claude-opus-4-5", diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 979e04188..1ffb6724c 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, @@ -1322,6 +1323,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); From 7b72b35cca273f6e96c68ddc105744b0eacbaa39 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:07:26 +0100 Subject: [PATCH 36/55] chore: update doctor migration hash --- src/commands/doctor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index ac35fbe30..22696e9e8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -49,7 +49,7 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { runtime, ); if (migrate) { - // Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. + // 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"); From ecef49605be967cff4a730d42b3660afa452eefa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:09:41 +0100 Subject: [PATCH 37/55] test: cover gateway legacy auto-migrate --- src/gateway/server.test.ts | 69 +++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index f9e059693..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); @@ -193,20 +213,20 @@ vi.mock("../config/config.js", () => { } }; - 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: raw as Record, - changes: [], + config: testMigrationConfig ?? (raw as Record), + changes: testMigrationChanges, }), loadConfig: () => ({ agent: { @@ -286,6 +306,11 @@ beforeEach(async () => { testGatewayAuth = undefined; testHooksConfig = undefined; testCanvasHostPort = undefined; + testLegacyIssues = []; + testLegacyParsed = {}; + testMigrationConfig = null; + testMigrationChanges = []; + testIsNixMode = false; cronIsolatedRun.mockClear(); drainSystemEvents(); resetAgentRunContextForTest(); @@ -523,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 = [ From 17e17f85ae7064379a71c47ca4f7c9ecb041cf46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:10:09 +0100 Subject: [PATCH 38/55] docs: note gateway auto-migrate --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c4cb842..1d82e2c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,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. From b135b3efb9184a562d9a0b35331f8b521c8f5284 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 2 Jan 2026 00:11:03 -0600 Subject: [PATCH 39/55] Discord: add slash command handling --- README.md | 2 +- docs/configuration.md | 6 + docs/discord.md | 18 ++- src/auto-reply/templating.ts | 1 + src/config/config.ts | 20 +++ src/config/sessions.ts | 2 + src/discord/monitor.ts | 290 ++++++++++++++++++++++++++++++++++- src/gateway/server.ts | 1 + 8 files changed, 336 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 102bb644c..78c372992 100644 --- a/README.md +++ b/README.md @@ -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.requireMention`, `discord.slashCommand`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed. ```json5 { diff --git a/docs/configuration.md b/docs/configuration.md index 38bee9cbf..5a90a0a53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -175,6 +175,12 @@ Configure the Discord bot by setting the bot token and optional gating: token: "your-bot-token", mediaMaxMb: 8, // clamp inbound media size 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) diff --git a/docs/discord.md b/docs/discord.md index f482588e3..ad6bf7126 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -25,8 +25,9 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 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 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. +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 `#`. @@ -47,6 +48,12 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea token: "abc.123", mediaMaxMb: 8, enableReactions: true, + slashCommand: { + enabled: true, + name: "clawd", + sessionPrefix: "discord:slash", + ephemeral: true + }, dm: { enabled: true, allowFrom: ["123456789012345678", "steipete"], @@ -77,10 +84,17 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea - `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/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 3e3c0ac59..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; diff --git a/src/config/config.ts b/src/config/config.ts index 2ea0300bf..3525ebcf7 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -192,6 +192,17 @@ export type DiscordGuildEntry = { 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; @@ -200,6 +211,7 @@ export type DiscordConfig = { 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; @@ -936,6 +948,14 @@ const ClawdisSchema = z.object({ .object({ enabled: z.boolean().optional(), token: z.string().optional(), + slashCommand: z + .object({ + enabled: z.boolean().optional(), + name: z.string().optional(), + sessionPrefix: z.string().optional(), + ephemeral: z.boolean().optional(), + }) + .optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), enableReactions: z.boolean().optional(), diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 4768fd368..7457bb0d2 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -349,6 +349,8 @@ 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. diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 78c5708ea..5a542c7b2 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,5 +1,7 @@ import { + ApplicationCommandOptionType, ChannelType, + type CommandInteractionOption, Client, Events, GatewayIntentBits, @@ -11,20 +13,23 @@ import { chunkText } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.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"; import type { RuntimeEnv } from "../runtime.js"; import { sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; +import type { DiscordSlashCommandConfig } from "../config/config.js"; export type MonitorDiscordOpts = { token?: string; runtime?: RuntimeEnv; abortSignal?: AbortSignal; + slashCommand?: DiscordSlashCommandConfig; mediaMaxMb?: number; historyLimit?: number; }; @@ -86,6 +91,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { 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( @@ -111,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) => { @@ -376,6 +387,159 @@ 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) => { @@ -614,6 +778,88 @@ export function resolveGroupDmAllow(params: { }); } +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, + }, + ], + }); + 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) { try { const channel = message.channel; @@ -659,3 +905,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/server.ts b/src/gateway/server.ts index 1ffb6724c..d29974391 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -2254,6 +2254,7 @@ export async function startGatewayServer( token: discordToken.trim(), runtime: discordRuntimeEnv, abortSignal: discordAbort.signal, + slashCommand: cfg.discord?.slashCommand, mediaMaxMb: cfg.discord?.mediaMaxMb, historyLimit: cfg.discord?.historyLimit, }) From fff9efe8a83e2f6b8d1359cad14e0f280bee3506 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 2 Jan 2026 00:44:06 -0600 Subject: [PATCH 40/55] Discord: auto-register slash command --- src/discord/monitor.ts | 52 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 5a542c7b2..1b3b95101 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -844,6 +844,58 @@ function resolveSlashPrompt( return undefined; } +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(); + let hasCommand = false; + for (const entry of existing.values()) { + if (entry.name === slashCommand.name) { + hasCommand = true; + continue; + } + await entry.delete(); + } + 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, + }, + ], + }); + 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 findFirstStringOption( options: readonly CommandInteractionOption[], ): string | undefined { From 5f103e32bdeea0f5d29c4ed075bccbe876f4eac3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:33:52 +0100 Subject: [PATCH 41/55] fix: gate discord slash commands --- CHANGELOG.md | 1 + README.md | 2 +- src/discord/monitor.ts | 52 ------------------------------------------ 3 files changed, 2 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d82e2c00..fd4368499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - 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. diff --git a/README.md b/README.md index 78c372992..9f0dfd62d 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Minimal `~/.clawdis/clawdis.json`: ### Discord - Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). -- Optional: set `discord.requireMention`, `discord.slashCommand`, `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/src/discord/monitor.ts b/src/discord/monitor.ts index 1b3b95101..5a542c7b2 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -844,58 +844,6 @@ function resolveSlashPrompt( return undefined; } -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(); - let hasCommand = false; - for (const entry of existing.values()) { - if (entry.name === slashCommand.name) { - hasCommand = true; - continue; - } - await entry.delete(); - } - 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, - }, - ], - }); - 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 findFirstStringOption( options: readonly CommandInteractionOption[], ): string | undefined { From 1e04481aaf76c577fb4e5a89e7898331cc0e6126 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:37:45 +0100 Subject: [PATCH 42/55] style: format discord slash handler --- src/discord/monitor.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 5a542c7b2..e5449074a 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,8 +1,8 @@ import { ApplicationCommandOptionType, ChannelType, - type CommandInteractionOption, Client, + type CommandInteractionOption, Events, GatewayIntentBits, type Message, @@ -12,8 +12,9 @@ import { import { chunkText } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; -import type { ReplyPayload } from "../auto-reply/types.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, warn } from "../globals.js"; @@ -23,7 +24,6 @@ import { saveMediaBuffer } from "../media/store.js"; import type { RuntimeEnv } from "../runtime.js"; import { sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; -import type { DiscordSlashCommandConfig } from "../config/config.js"; export type MonitorDiscordOpts = { token?: string; @@ -422,7 +422,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { interaction.channel && "name" in interaction.channel ? interaction.channel.name : undefined; - const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelSlug = channelName + ? normalizeDiscordSlug(channelName) + : ""; const channelConfig = resolveDiscordChannelConfig({ guildInfo, channelId: interaction.channelId, @@ -460,7 +462,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { interaction.channel && "name" in interaction.channel ? interaction.channel.name : undefined; - const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelSlug = channelName + ? normalizeDiscordSlug(channelName) + : ""; const groupDmAllowed = resolveGroupDmAllow({ channels: groupDmChannels, channelId: interaction.channelId, @@ -812,9 +816,7 @@ async function ensureSlashCommand( const code = (err as { code?: number | string })?.code; const message = String(err); const isRateLimit = - status === 429 || - code === 429 || - /rate ?limit/i.test(message); + status === 429 || code === 429 || /rate ?limit/i.test(message); const text = `discord slash command setup failed: ${message}`; if (isRateLimit) { logVerbose(text); From 5ecb65cbbed94d7c6b0e76cc12d0b23a01cef88e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:46:48 +0100 Subject: [PATCH 43/55] fix: persist gateway token for local CLI auth --- CHANGELOG.md | 1 + docs/configuration.md | 3 ++- docs/onboarding.md | 4 ++++ docs/wizard.md | 1 + src/commands/onboard-interactive.ts | 19 ++++++++++++++++--- src/config/config.ts | 3 +++ src/gateway/auth.ts | 2 +- src/gateway/call.ts | 16 +++++++++++----- src/gateway/server.ts | 4 ++-- 9 files changed, 41 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd4368499..2e465db48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ ### 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. - 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. diff --git a/docs/configuration.md b/docs/configuration.md index 5a90a0a53..7d7f30964 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -555,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" } } } @@ -566,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/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/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/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 39f9c4a53..3edf022bc 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -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, + }, }, }; } diff --git a/src/config/config.ts b/src/config/config.ts index 3525ebcf7..cd979570b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -351,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. */ @@ -1097,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(), }) 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 4fc92e303..599638b4e 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.ts b/src/gateway/server.ts index d29974391..e3b89bf2e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -660,7 +660,6 @@ type DedupeEntry = { error?: ErrorShape; }; -const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN; function formatForLog(value: unknown): string { try { @@ -1371,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"] = From f57f89240987ec2749a7115493ec60687eb0f285 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:48:19 +0100 Subject: [PATCH 44/55] fix(macos): clarify gateway error state --- CHANGELOG.md | 2 + .../Sources/Clawdis/ControlChannel.swift | 42 ++++++++++++++++- apps/macos/Sources/Clawdis/HealthStore.swift | 13 ++++- .../Sources/Clawdis/MenuContentView.swift | 4 ++ .../Clawdis/MenuSessionsInjector.swift | 47 ++----------------- 5 files changed, 64 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e465db48..a7f140abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ - 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, avoid duplicate gateway status rows, 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 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 5ac5a37af..4dfc30fa4 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -365,6 +365,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..fd3fb7e69 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,16 +189,7 @@ 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) - 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( @@ -265,36 +250,14 @@ 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 var shouldShowConnecting: Bool { - switch GatewayProcessManager.shared.status { - case .starting, .running, .attachedExisting: - return true - case .stopped, .failed: - return false - } - } - private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem { let view = AnyView( 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) From ad9d6f616db58cacdd36e6436ebad08a8a0d8f31 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:03:38 +0100 Subject: [PATCH 45/55] fix: improve onboarding auth UX --- CHANGELOG.md | 2 ++ src/agents/system-prompt.test.ts | 2 +- src/agents/system-prompt.ts | 4 ++-- src/commands/onboard-interactive.ts | 21 +++++++++++++++++---- ui/src/ui/app.ts | 15 +++++++++++++++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f140abc..5fd95fc08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ ### 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. 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/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 3edf022bc..4b14de7f3 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -495,9 +495,18 @@ export async function runInteractiveOnboarding( note( (() => { const links = resolveControlUiLinks({ bind, port }); - return [`Web UI: ${links.httpUrl}`, `Gateway WS: ${links.wsUrl}`].join( - "\n", - ); + const tokenParam = + authMode === "token" && gatewayToken + ? `?token=${encodeURIComponent(gatewayToken)}` + : ""; + const authedUrl = `${links.httpUrl}${tokenParam}`; + return [ + `Web UI: ${links.httpUrl}`, + tokenParam ? `Web UI (with token): ${authedUrl}` : undefined, + `Gateway WS: ${links.wsUrl}`, + ] + .filter(Boolean) + .join("\n"); })(), "Control UI", ); @@ -511,7 +520,11 @@ export async function runInteractiveOnboarding( ); if (wantsOpen) { const links = resolveControlUiLinks({ bind, port }); - await openUrl(links.httpUrl); + const tokenParam = + authMode === "token" && gatewayToken + ? `?token=${encodeURIComponent(gatewayToken)}` + : ""; + await openUrl(`${links.httpUrl}${tokenParam}`); } outro("Onboarding complete."); 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(); From 87be5c737cd9e14c98023553ae6d01f97c7561cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:12:57 +0100 Subject: [PATCH 46/55] fix(macos): suppress cancelled node refresh --- CHANGELOG.md | 2 +- apps/macos/Sources/Clawdis/NodesStore.swift | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd95fc08..8998ded54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ - 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, avoid duplicate gateway status rows, and auto-recover the control channel on disconnect. +- macOS menu: show multi-line gateway error details, 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 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 + } } From c93d02891a23aa478cf2dd7f7644f217771627ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:13:05 +0100 Subject: [PATCH 47/55] test: cover control ui token url --- ui/src/ui/navigation.browser.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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(""); + }); }); From ebf86499406f37e88b39e9e943098ac152a15568 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:22:23 +0100 Subject: [PATCH 48/55] feat: add songsee skill --- skills/songsee/SKILL.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 skills/songsee/SKILL.md 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. From 68806902ff31b9bee18031723208c672ca9e816b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:27:21 +0100 Subject: [PATCH 49/55] fix(macos): show gateway in devices list --- CHANGELOG.md | 2 +- .../Clawdis/MenuSessionsInjector.swift | 68 ++++++++++++++++--- apps/macos/Sources/Clawdis/NodesMenu.swift | 20 ++++++ 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8998ded54..24940c720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ - 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, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect. +- 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 diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index fd3fb7e69..312744219 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -189,6 +189,12 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { menu.insertItem(topSeparator, at: cursor) cursor += 1 + if let gatewayEntry = self.gatewayEntry() { + let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width) + menu.insertItem(gatewayItem, at: cursor) + cursor += 1 + } + guard self.isControlChannelConnected else { return } if let error = self.nodesStore.lastError?.nonEmpty { @@ -214,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 } @@ -250,6 +248,58 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return false } + 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 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 { let view = AnyView( Label(text, systemImage: symbolName) diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index 882b7ec3e..792bb01c1 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 { + if self.isGateway(entry) { + return entry.displayName?.nonEmpty ?? "Gateway" + } 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") From 25e52e19dcadd0e009e6016349c9e2e50b9a499d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:28:34 +0100 Subject: [PATCH 50/55] fix(macos): return node name --- apps/macos/Sources/Clawdis/NodesMenu.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index 792bb01c1..70635929b 100644 --- a/apps/macos/Sources/Clawdis/NodesMenu.swift +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -14,7 +14,7 @@ struct NodeMenuEntryFormatter { if self.isGateway(entry) { return entry.displayName?.nonEmpty ?? "Gateway" } - entry.displayName?.nonEmpty ?? entry.nodeId + return entry.displayName?.nonEmpty ?? entry.nodeId } static func summaryText(_ entry: NodeInfo) -> String { From 9b3aef3567391a69d588ff4f7128a023b81a9cba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 16:25:28 +0100 Subject: [PATCH 51/55] fix: show skill descriptions in onboarding list --- src/commands/onboard-skills.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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), })), ], }), From 8de40e0c10255786adf10082b1ac6beb0df31ccb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:27:33 +0000 Subject: [PATCH 52/55] feat(macos): add Camera permission to onboarding flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'camera' case to Capability enum - Add UI strings (title, subtitle, icon) in PermissionsSettings - Add ensureCamera() and camera status check in PermissionManager - Add CameraPermissionHelper for opening System Settings 🦞 Clawd's first code contribution! --- .../Sources/Clawdis/PermissionManager.swift | 38 +++++++++++++++++++ .../Sources/Clawdis/PermissionsSettings.swift | 3 ++ apps/macos/Sources/ClawdisIPC/IPC.swift | 1 + 3 files changed, 42 insertions(+) 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/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 { From 6b7484a8856b88266237138761585815112f162f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:46:08 +0000 Subject: [PATCH 53/55] feat(skills): add local-places skill for Google Places search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wraps Hyaxia/local_places FastAPI server - Two-step flow: resolve location → search places - Supports filters: type, rating, price, open_now 🦞 --- skills/local-places/SKILL.md | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 skills/local-places/SKILL.md diff --git a/skills/local-places/SKILL.md b/skills/local-places/SKILL.md new file mode 100644 index 000000000..eefb74184 --- /dev/null +++ b/skills/local-places/SKILL.md @@ -0,0 +1,87 @@ +--- +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 ~/Projects/local_places +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. From 100a022ab76eb4ba7c67e7e7142fa1d4d0ddff16 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:47:42 +0000 Subject: [PATCH 54/55] feat(skills/local-places): add server as submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Links to Hyaxia/local_places for easy upstream updates - Updated SKILL.md with {baseDir}/server path 🦞 --- .gitmodules | 3 +++ skills/local-places/SKILL.md | 5 ++++- skills/local-places/server | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) create mode 160000 skills/local-places/server diff --git a/.gitmodules b/.gitmodules index 096e18c88..673aa118c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ path = Peekaboo url = https://github.com/steipete/Peekaboo.git branch = main +[submodule "skills/local-places/server"] + path = skills/local-places/server + url = https://github.com/Hyaxia/local_places.git diff --git a/skills/local-places/SKILL.md b/skills/local-places/SKILL.md index eefb74184..8e62f4cef 100644 --- a/skills/local-places/SKILL.md +++ b/skills/local-places/SKILL.md @@ -12,11 +12,14 @@ Search for nearby places using a local Google Places API proxy. Two-step flow: r ## Setup ```bash -cd ~/Projects/local_places +cd {baseDir}/server +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. +Server code is in `{baseDir}/server/` (submodule from Hyaxia/local_places). ## Quick Start diff --git a/skills/local-places/server b/skills/local-places/server new file mode 160000 index 000000000..bfc3becfc --- /dev/null +++ b/skills/local-places/server @@ -0,0 +1 @@ +Subproject commit bfc3becfc48af865722ef35ee7cca753519dd93e From 921e5be8e640c65992b17fbe721d288f56c2e40e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 15:48:24 +0000 Subject: [PATCH 55/55] fix(skills/local-places): copy files instead of submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Submodules are pain. Just copy the Python code directly. 🦞 --- .gitmodules | 3 - skills/local-places/SERVER_README.md | 101 ++++++ skills/local-places/SKILL.md | 3 +- skills/local-places/pyproject.toml | 27 ++ skills/local-places/server | 1 - .../local-places/src/local_places/__init__.py | 2 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 218 bytes .../__pycache__/google_places.cpython-314.pyc | Bin 0 -> 14487 bytes .../__pycache__/main.cpython-314.pyc | Bin 0 -> 3794 bytes .../__pycache__/schemas.cpython-314.pyc | Bin 0 -> 6290 bytes .../src/local_places/google_places.py | 314 ++++++++++++++++++ skills/local-places/src/local_places/main.py | 65 ++++ .../local-places/src/local_places/schemas.py | 107 ++++++ 13 files changed, 617 insertions(+), 6 deletions(-) create mode 100644 skills/local-places/SERVER_README.md create mode 100644 skills/local-places/pyproject.toml delete mode 160000 skills/local-places/server create mode 100644 skills/local-places/src/local_places/__init__.py create mode 100644 skills/local-places/src/local_places/__pycache__/__init__.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/__pycache__/google_places.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/__pycache__/main.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/__pycache__/schemas.cpython-314.pyc create mode 100644 skills/local-places/src/local_places/google_places.py create mode 100644 skills/local-places/src/local_places/main.py create mode 100644 skills/local-places/src/local_places/schemas.py diff --git a/.gitmodules b/.gitmodules index 673aa118c..096e18c88 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,6 +2,3 @@ path = Peekaboo url = https://github.com/steipete/Peekaboo.git branch = main -[submodule "skills/local-places/server"] - path = skills/local-places/server - url = https://github.com/Hyaxia/local_places.git 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 index 8e62f4cef..bc563d419 100644 --- a/skills/local-places/SKILL.md +++ b/skills/local-places/SKILL.md @@ -12,14 +12,13 @@ Search for nearby places using a local Google Places API proxy. Two-step flow: r ## Setup ```bash -cd {baseDir}/server +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. -Server code is in `{baseDir}/server/` (submodule from Hyaxia/local_places). ## Quick Start 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/server b/skills/local-places/server deleted file mode 160000 index bfc3becfc..000000000 --- a/skills/local-places/server +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfc3becfc48af865722ef35ee7cca753519dd93e 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 0000000000000000000000000000000000000000..0a17848a45a0a0a69ee91bccfa284ee891b61154 GIT binary patch literal 218 zcmdPqD`4)G4d|7Hy zab|vAe0&wFfu5nBfuAPRE%x~M#GIV?_#!5twv`N@L8jbt(hn^Ls?{$pNzE)sElJf6 zD9X=DO)e?c&&f|t%!x0^NlZ=!N*5)g3dF}}=4F<|$LkeT-r}&y%}*)KNwq6t2O0@- eU9ljL_`uA_$asTWqC>xd{RW?C6L%3SP!s^PnmC#O literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..94944facf8666a5131c4bb4a8855f5acfed649c5 GIT binary patch literal 14487 zcmcgTYiwKBdFS%-^8M0V6!oAUwoE@nH?b`{@guTLNwy`KzLJ$>kCcE8cC3{5KFht%~#K3?p1v+#on(QxSx)6J-q5%pF$d3W?EZ433XWw`3 zgS1T9?i}>kJkRf*$9KN-z0T1#kJ~{Y?EdeUlh@k``85{wKwAi5?&cUmX31H?Gsj3j zBQq_SHyty{CYrJ`3n_cd+|S9J)@G3{P-i}7?YGG`TE`u;_d8@qzf*ShyJT0tTXy$* zWKX|W_V)W^Uw?&M(L&C!(GGq1V%kc03vbx)$uCr#|9p>cPGqusM3R4>I(h5Funw(~)*f5zg^;!wJ1Y?TK%f54_6Lv!E zq?t^yStd*5dfp@Zd9U2S`{Y0kfw3#<2w!Q;v5~KWa&;X^`Q#?P2J*Eu-^|xRzMke= zct7MDXg9Myyr~`5)S}HlNkZE`$2Er-#TiehGjd!`X429Sl-Zv2G$ii+k&$TRjd3B1 zEg_p?8HmdR=?SHFATv&(c|ppgE(*Nxl_^1zmAXgjr7UzQD6VKKJ}w*;g7XK^s8Ya)}GND1+5QtBGdOolIhX{I3s^G?McYS&7NFUlV~5{V5&Mk52UUOp00 z>Pl;l4MkpwMuvuujYgE}(uTp4M(Toh7zjXa$djE~2s zL{Uh~C$mBtKI+d*iPHA8@P-_XPY5HK*M;=Vw_sAGvw371#`yZc#Szt^L}6ko6&D{G zqwKhJ8Szx~LMAN?O--H?#O;@ab5c?kPK(KzQ(q*K=*-4T&poRan7cWz&Cuw|% zAP<8VGjPB9l4p5aYl20CItM_9!MyH2~A@gRl2(WQ5L1JABS z-53eGZ&UihrzJs@!jdc`vw|#yqhjXE!niDjOG!U0iQ^AfsAok#8QE#Y6^m(1L5Rha z#&rwoDsAn9k)CE!<6Qfxev5EOe%M-jA`B_VZQtcUL<9C!0(F#w5Ti^Y=ZqzsFx ze{?Zw3tdPJ0MC*_pnW+IUJ8USn{V6d3;xzW_lV8V^8ejQu@2{85e0clOh51>lH>Ag zk}Pfy0;fGA`k^tzhz*da`$(rOB7ula5S_pV9m5Q_=?;PDYMLg+X#o%o8iCpG?tvm; zn3ThCRHJ+C8=B~}+)l(GRD?`o8zhSP%TgwNgAv=IK&Pi-RNRV<)$5Uoz;wPEBE3~} zU%T?PWpCS(w=M7O$k{rIGuHQ2oAJ{e35rOu6NhF2flox;Nt|>Mb?u0=_N(JGDNY1{ zXut^pV(fRjAhRLraJuWldX&k)OT}#fREn^jP<|9)2s(t2l@QI6&q@)Nv$fGlYFi<; z0>}_B7$D{}hzKBNM=Um(Nlc|M4`=o(Q}LA6;*G`5Cq+q4CDTGW6N`x*@UgmSwyc=R z3ZgtM(ka!%VtAU8<1vskV)ERSEJy&4R7poysS)>6#Hbg3QV^nVl6xjN>i29wjjI-7 z@vNBuxp!GTr+$o|JalZ#PytA0?qVN^xI)CgalUB5n>P zftiwn1jO+8gM_DP7=98SvsrSVI2p?wjOK?|Fos?FVGLiAXY=P3?YCT zNOTo6%ZxHesTd|a$lk{|wM0OX=1n}?t@Qv*-^_EoWt8Eqqa@72x@}6$!+K6n^jPdf z7*|uO(3W6dRlmxl!hKW*(Vo6Y~YsPgXla@i`?-&6+V8$2EW>ZO3fe7P~ zo$fgPR}5<}2=N36B(VqJDE5y)W|pjYd~?ZfCv)}Tyl3ZZ->;mNE1p21K2WIIv&OMr z$0}j%j#US-*X8QB7HswNodsLN{P9(**=1QJX3kRFCOoqk&{m<_v>h^!AuE%LYCyl0 zw}m+r*cx}96yyt;1SKzpof1=uH5;E!W#WkkH4iiF;GYGDKv$uQr-Vq)#bbF9QePZ? zk}CG@6AuGy9HN5DyUJo(>sQl`eQMfwoTi^~)ps~(R~KxCF7yEAh`Qi5K*1~tkS9|n z&va|(dXb4SQ>H04Ms%*9HIJI327T&-5ICQs%$QX#kFq*-kD7Q>w>F<3?Sc)! z&_c%CYL1XsIQ@%u5+vJ*aUy{B8%Yujk|u!1e*=LR*(Q=?f~705>HQQ_3rSStw^_@W zQ?FGwRXtDO}HAo88bu0B93xATY-&v?>T(y(> z&36e`@44K6pCz6xYt_URnBSXscI3<*_iLaAy7>K?hqx-{4qZ942BH(X_XHN%+^&(^ zp3}M4&gN`qf4|CN{R2wb-)QOCOx|zlsp38+HpqTl>T9BktFel0SStp;vi%mbBaragE$DVq6;G=E>%7}PN%}dD8kPW1!l&7hd?y{ zRbVcR|7H~}6?qK6(tzYmXZIt>;Oxq&Ryu(vK<8`R0H&Pi7zD$kRAeNA+V_Z)b-@J! zWR!7{=h!IH$GV^&>2@5w%-6v4OhXD244D<{`D9842CZ0ue_zUo355gN0<)Y0ftt($ zho{ILg%KX&?iI0fQ%MlWsJ(#J0bhu(0u03}DhQxLhjxD0@?pzj%jJo=%$3ZtXVWdu zrjHW9?5&GOu2(AgmD94WW| zVH50r+5Ivo%C|8^fa+k{43mH!a2D%=(Zc}tfXWrmm4}R$VYr}}l~t=s zXr*3POa&GHW!vCOhxR21r}e@3vP>I%>D0cgUH=_1#AO1k3E(ECXc-vrS+Y~6F7#N! z472PQTE8@mvZbpVGM#``Q6Hu$%fh%WIxeI61)&bSf^Nl*hx!!d0=6tTgocwdU}IyB zx`q_Xcv2is36O%Q6GX+90^>oRLPI>2Nl&O*ix^KNr@+~#`-u)G<4{Vb$5Q~AKrK|U zf)$b;%3M!=(&PAk+B15MKK|eqSsF=@X z64P3jDm?PlqEN2X_@P8ymoW-`5g;fVi2xS>%g$DHyJqvkrF>1eV5_{^cYW~M;3~&9 zT4s-erfc&qTmA4qXZ05v+m{==mKwV*n=ik%Wb=P!^(?Lx1b7Z@o497INkAweV#tWx#U>E*~hgZ~frF`v-1T{H*=? z%SW#^FS&xyw(JZnIRm$xorUI2%gtR&&0WyuzjBf`H7z-tZaG7RVCQo1xuxK9&=gp5 zHPN<~WoJvy+49-GmzMVp-r6^Kmo>F}e!g$;CU@J{v2c3Xw=L(}wzO~X4%WkmO-rs8 zScA(qd*XNhYlk|?0x13I-r7j5`I)r_g%#tM#9ru7wI$uEb6eN#pQJzhEqo}UX9-P7 zD-*thQLcW+DyBDg(Bh*40`+UEKzx&aIcgm538)3e4E`-(o`%o>AT98WEg0{Re&7J* z=4m(j0~pWPXqE}wArR%7o&yt=3EZ5a#o{ahm!tiLNpY8vy8R z(TTerpZoI8rkuH{IMXi@p}tg7qK@J-Pm9%m#l@g~gKUXqwjvt0=4!VgR=4JMzC_E# z$pRn^UldxEs!M|KdLll3RFKBOexO&1<BBwv3FKnA9_p{o|6`2(w%EP|^RlH%+DmWB$5DcAij#Iq%C zB(Rssb6{T;g&^9`MR#lw!(%A!XxL>|Q)*q5vx-c6!3~h;LqR)cSVB=;-MYZDptqOl z0cy)wGbK(mTn`P@UMt%NtjzKp%UgBV*99hkRzj8jWii#g0$Fy1jUp>s7kFnE(O)lQ zxh`<8jc`8F`xCGztRt2vZrHjohU}Y$5o_5VUA$ZGajfYHupV7~(bEn3^y=+nEl=pt zr$g%L20d2j?PIM^=&@3V)YA=mtkT=Z+Mm#4wGOGLd`-7TDSAx{Iq_4~Y8%^R^k&&$ z4{P5K7HG7&sJd~)ca?jO`2)0(ue^`X0l6pQ*2K`|v03Q?RT zuOk%S`sWc~6l?D@;MP~QY#l|*R?NU6l&UhXA>iJ}Mu&;PAZEPEgWDDL;fMG)@R5jW zwPqx%hAZt$%wo0sp(h-07Y=S{X(}a4iWgfz4CwZAs7gf6Lp{hM+NnxqY&g=xAL)lpnbL<*w7_YP($$L-F_J3vtdpodX4a`fo zt!*nd&z$RutKiyQa8)n6f@tTus^rSOql*U|qx zoU7_9y{)oxuQG+o2J~!xAFSwAAMw=8y>{ib`TB+D^6t&Ey#^=*;|@zI>(?5ItD)elE7Z5V=X}Sxa57)N3)Y6W zZ7gT`?OK?)YS#z{D>QI*SVs{DcXewvKoMRH4Bhse%$*_toetM9D{4<;J;sW$uyb>1iR_uw&#s2`1pNPnpj4Fv@EzA@rs{@4TO?𔴣^if{SeGuq z^mGF_x87c&gp^ln9#t&L!xa^aqN-9J@=?X2Je;9eY_JAHo!Fqgs74f3o$@0+qDGXc zGUdOh(ebCJd~LTzYgAz{+}|Y%!{e#a4hlm#`YS37rS#`0(il}3OzRYex*40MFoYGh zFETPDUIP*nzlRa30OI#C`U{NqWAv98{S`*@7`=?ta^FH?V_Ye2~4@Uo77T+=|R;@?49i~Pu3h7WqQSyTO=4fQ3 z=h(pTdZ{ZeVqZ5f+KCbR4e8q*@t-h9nH-AS^%C|n*1Zi8yqj6Td>IKVVP{mJs?xLq zoP6rbznJ<~L=ry&2+AWbmPpiv{>4`oj~hajo0k@j-rjm3?>+cO5vOZD0@AXg{(9hA zV18s_Z@yytiaWSa_d($Oz~Y&F>x+5!eh^^So!6YX#;&|Cyi(D!VEw@PzH{+-KG>bF z*jw<{&b@i%O}OFqcC1u`C;o#s-+%Mw{(R?4`Rbkm$m-XxyguKV_q1vGTm!yN(fXVp z$ooPoo`(7E_g;MG#a!q9eBeOdb8w|Xn?i2K;e6|ne8thVIu}Y!7fMaF_ya{~ahWvzHv;Y^Srs9hfo_)D;%-~Fhlx0WRa@Ui* zxkSnu+$w5tCVdQ$vRwHdpw?G}(sLzJ)-b*CPZrAe0aDfmQr7;Elx3ra9MyX*k+S9y zb2$^7cb6FL7BJeuR0pGd7L0Zq7^jATUhJ{N5Vw`>(ZhQUqrE71%ELY?3CqJ7lCVTB zm4`HBqAr(4%EKA5k+1GXh6X|Z0u1JFXa;k;+Cs>P6%1>jUkp^f)v9?y4Ee^5J)RyM zAf?Ogd{MrY?!2?OyCWPJ#I7uNh$}IOYsER3fjEZI8H}!C^prAgNVSrvqyYw_<+V!DkjiT?pmls}y-kyyG7Sa8+N z*Dd?Imi%2e9Ut}mc<_gVIp^`&qq=Rl?O@(}=#L`Ap2{X{d^Ve~#OzyhTF}U|=|)~H zakVdY-*!HqGe7^^wa_z4r=Pfe5te+yI(r%R6HixUKl{mk4)UQ8K1)771#jKqS+WRR zAD-5s7Xkj%L_CNk3*5?M0hzmag0`QJ!yDvml4?3iHEyi&hdR>u1pyxZ(u+t{W>IlP zl_2V7pj3v3mpT{3LzqMNhGGZRW`MdcRE0{yN)G<8WKt5pgHU))CjL7{Kf(wZFnzM8 zI!0c_B6>$uAI8@)_clhCG5Rh>KfnmpB}#$da$(^emBN8{3H~AqIm6DKs+MKjufEeh zDE=4JA}5mE5X0j+hWRCF`!(_Wg4lmaYJW{!za*{q&349gpFngMqt#l{(Dfhx{|ZwSm>&Kjg~6OEf`+z$8u_3&x+ zs)y`3&MfafxwQM_?cJwt*YQicPrX00AkA~{Iv{(ijz=hqjjLW|2~)M&;NU7SSH0S7 z=BjA!ex-wf2lWu$Yj84_dyVi+^KwroZIK#jyM<){vGhGi!ZeK?aBi7Clr zcUMBpqJW$haE!EwQ52{j{E&yDu+aj2?Vmwc1}v;&AV7@-LEk#FM~al(s6d;Q zxgWDLyK}#t`R)4q;ZPreGWCyZl?@Lef5$Yc^~)5zI)c6@8f;4@0ktcgFKiI@lZa@!&2s*J&=#^NIuG= z`52Go5AuWge%_xXMQxPmKHXnb3a@wE>b5Y~rw2}ecUW>tR?&l}+9N(#B!#Rq(_4d0 z=!D`(c%nF1INm$!^!0E@Lq8BFdL&M&krQn-J_I(WUbQLuc5I?x6We7o3^u1&(h%BD!dCqfs?W zmQl92S*bD+r53ZRE0$5UOVXB!5VK5LT5=Rw#w`Zt+HbCb2@mJ)|P9j*qJ>G6_ z5xOGR>sW<5Lr1yk`N^#mexn7glgri2k1I8H ztY(&O+V)S6nH4%SJvliENV$21S1sXX7G1F@YsT?os_hTRuI%8_bmtnC`eF&AxE;yF z1${h)Ej*}IGEZ)()4zgq1lcfoPgzoQWdUZKve3>ELb|=<>Kj9ydCa9Tl2Koxp72nM zFVi|W=qguATxg8rsdzrLY0cyeJxGglxvStd%Q0?M8Wy*51!{j}l{m}wengJZ(r!V` ztkkoOTOw!}X1#86lUs&C55t=14a3eV`GzP;aucgxmw$Ksfp%~DH+4DOf@xC1s#DNo zi38HwD$yYtoo^A|n#Gv8XbBg@(c&XQQ=ma1&I!e>Kn$4DQRp6m9}7XXO1=sX+*@e{ zMp~{Bsk#eu;7ez(Gg5bd%yqSkwXQ9=z(kgu21vW;01SkeIvFA~>*Ep~1W`t#s7Y}v z>P3M9_<-R!Nv=N1#H)LW*Y-t1m!e3MuN36<(<6c|ND=)#r9;3c+|ERj zEF9>lKyXKQ`s)7nIc0~awGQ=;ktwaBWL(WRy6ls(kbY8jJ$019zijHJUA}b|`clrI zSNS8Hg77#T1nCiA5WdUIKt>bZl3nLkooD>QQ?(ZtEh^m7Qg~_>U$VMh-Mb(8Pf1B^=|n zkGKPO=5Eie`%bR=4tyTI8^$JZH}KS#cKJ*X$&7K}k%^+xFswr{B=*Y@+>BwqJFJpM z8c9DmyMfK0Z^%(M(LqfXbW^&TfDU8@CZ8V z7;m}8I~d~4gNea%?bpF~havqT>Vc?Z)7|ns+i#v z-{lVBfybp)UlAI7tnkBNPLemx;944t7uLX+@ef?1LYm_qSRhTX9}VZXR@H`J)A zR%#?xNhA3oQvFE!(2|#`4}FN#KIBhuv{r3Ksj8|{E8g6C)vCOt=iHfHk6{T&=}2?t z-gD2~nR|Zc{O*}~Ar=i0IF9`NviYYlA%DY8`%s*Quqg_J)Jcx0!dWsUFkuMW;#qM@ zV$zh%Vp>z+P-O>O_%3C z7j1Fgu*ITbD|5w)&TOg31q_kwgI|C1%{LFGLBJgWO)KjqL(}Y#rj;s%S`ph3O}kms zi|$BV)87F|~iRvN^z&u>|pka-H zxI^wI`>@aHH;Nr75D&*8&tYqzEr1Y+^%97udbBC; zyWc;seDl*A^{IxjCJ#H);Y{27ix|NnY|5NcI(KK7l_;u$KAQDI zUI;moQ&iv4Hn{{;Ka2;s4+ZdH5PBhxwV;MOSrh{LP}C?N4I@zEj+E*5xMca_aHUYLkC0%S|+&18`O@S>COc&d@+RGBE0GRjL{?lfWK@wyiCW@xT zerz#1oBnA!TPZ`j5)nlWE5GPB3=(8Jki_6 zp^{nFD1=mb-kshov(EI9DmC+lRx~~^idIIXdtm~b-%>Qn^K9W^2wtW=mH&pZc&kDS zd32oXJR(CI;1D*`(5RCck`s@8NJs&6gXA4yK~M!%R3%lM6k)OQ10*$D0b;Bf6mPM8 z)L=DQ<}nDt?VGF6lFlrge}rN z1iUjNW^bRlP%bW}0l+lkmo}}mRjXDjlo^GIG%#NJCMs<2~W3hG2`Qon_}*6V`w~P?Ebc+w(F7itQSGl z$%c$9>-cdpZG6 zYX3p@QDv^|(pBprGP1V&^BSsx+`VrBoU8~l3W0`EVLEN%)M*#1dWf*%I9+i>l zF}QFa+6vEF+i#XRz?KXndFGc#mX3U8H2f>6slS9%5Hl;g&o@)ql}L6y-rI=$_g{X}b5^hr zh>uez1g-DHLTW8~xDZB@ZttLpdSWs|GtIQ4qeJd}CC3=K|di*0uy&y{+(#DSNxeTZgs_fGF1FxJPN%p*p=Cb0e-j?4NV$7&^wM z15N{uGD{|-FQ6h+)#qVvs@yQ#%|6mKqlMfJ4~)5Tpmq6HXC33q>sVzXYFdGW2)>t4h3_mEDqG0$lwt<`l0I;2MM; zFJ8eisD_~zac2psQRv0oURaGoFTs0B7EvX@G-XHL#xJFJYo(G-7wKE@Vs?OkJZWYj z>i{6*SDfvKLW3HX^)TvuBDT>Bw85n~Gn8dc%@_h0+a_VpUcrku%JY8&?CB_qIEYNN zE3Evp<02YeLU9?zk5F7i!RO&qbi)>jn4&lVrsw*=0n7-A-;TA@ z%&NkF0^@dySNC3=W^F$}0p+{pLKH6>KVjOViQ<2qvXVD>a z#>ja6!qU|>dE8ky8f{y*4-{|+n+TU@-4~ulR-BL&Af%8gcL>Q#Uw#O4{LT&9{cV0x z4XaZ8z;L;UV)5Zb?-toG@CoM>iqJbefRUEX1iLzhGl-6+Jm@%YbPKEZ&;$iqw0|5t zx98sQ>T?I`=a+yo4j?<+y!+0*msXP#$PR1rgyS^4ZJX01P{1K9Q{<4M~6FG05?<@a=RA1dD$71kvR91UtxGH*||LW(fDw21bjt%M+qEyF}`X$8V~@4pFL_>2AzTlh3w literal 0 HcmV?d00001 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