From 5bb0f2028ee38f2a00165d7997f9f0ab480cbc4e 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 56886cba4d8d775fec98e12a29218c6c684c32a2 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 313294c2681b0f5831de6e9ef0852d33674266c5 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 ce319bef6993a2bceab294058660a76cc653d50b 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 c65ee8e1f934ac710a729276034d7db65c3af910 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 eff4a3da2dc7b8f80b5f6df448464341b29f853a 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 a240ac2957348f170fbb9d6c2dd26c942a8e7719 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 3266ad6211e981ac91b8b4d7219ad3eb89644bf6 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 ece5d0d09f72e624a8dcdaf083f7b3310333f213 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 a8fc6f576138bcffcd9fb230d9c1f4a79b4e7989 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 0f84464d4b4a1ecc8de33abb9802d5894c5ca598 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 e19ef8605b4d2b23f50591b3d5883cf1d4c283e5 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 b2f7912e282cf5965c8cbf8ca249b3825278eac4 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 3a02d69c5a3d7bf3702bed3faf4d3a40c12f8e13 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 81de204a4085b3b72b5bad96b92aa343cc5e89c7 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 35655b3676ca63a0b3e02dfc190a5665d78332de 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 9101bda4b6e04ad23ce38e886955a43b58cfb7dd 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 aaf13c2351f1ec33c45e0565d69ed2fd036e9a3e 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 bd5bc3ce495dae2be747aed61c03ab8caa287cbd 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 86bf4e7becbc6e05b7bf28b794595e3fbb4f0155 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 2b247db925504a0cc970d1701f1bcedf13cecfc8 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 2c1b6c5d3549518c6fd17d0c62f6c5f79a54d40a 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 16ba7715e633e0f9bb963a3308abf94fa9231085 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 d1daba7f2a2a80fb47accf81880a8f6f10b20ebc 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 e2b433cef9eb858277dc6b5ed03de62210d9208b 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 2cdb9586e66b7fe30fbcce3274f95d2f29628430 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 fcacb85cc5b04f6bf6dc28f02ba01e7c11b01f84 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 81df95244f119bc54b12c81b8ab3823831d9b229 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 6ee42e2fb2dc241a981987dc8d88b65e86c66c07 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 9837233ef1990d6b78a2aafaf7e920a98f2690c2 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 4c9c360707d4429d4777e542637e27eb6494102f 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 e98d9a8a98ac3bb0ca03f678c9a6f63961c01f96 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 7d310abd0dca3c7bb02faa8d03db1e85ce5bbb05 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 d13fef230a6ea2dfcb512ea2cdf381596a1aef17 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 8bd302984e5de07655185b94f51b077c71e2408c 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 c18c57ea19b925d4f6724cdffdf56d16f0fd80f6 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 355426f7a316fa9857517acc4309218e39757a35 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 d9d2356f8b5d4d0d13ad60cf6e1f2a0c5cfe6582 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 22b9eb395e4a893e8246fe52ed1dffaeee384785 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 dd139a97ddc5f3551ec30ccbbea11712e77744b8 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 e19acc5caaac9c9c0038e798ddede2634e94256c 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 39850f9f3b2e3050244e89be44c496bf1893ea24 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 8c9c76ba01362e242a5bc15465a98a7de3302ef8 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 1e0f9f9e749b8899a96b8729fe027eec2dde9aab 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 b5d4193db3f4d457f5871975fc7ce3479b031455 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 8913c8a5078a4ba6bed465f07a852559572b9a10 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 d4a7dd0335c998a0002e4a62dd4992bc2cdc5f2b 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 b09d2e77c8acd9685e95c966bd532844f77d3a7a 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 2d88ded6665e10e3ec3e3154e8b6e8e4780790b1 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 189c4889c813b94f77a4f539017699cb9fbcc634 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 6b4e06b088525411622a21114ddee5a3ce010d4b 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 b6fdb743e30a05fcdabf640c7b09577cee205eb4 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 c11c268e155f93862f1723cfb80f36ca902f60ea 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 eb68fdcc9cbf47639bb8c1b583566e43969740a2 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 017cc7107c357d9945b84baef5ea640c289aa620 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