diff --git a/AGENTS.md b/AGENTS.md index 26b42fd9a..1da355f35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ - **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there. - Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item. +- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. - Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel. - Voice wake forwarding tips: - Command template should stay `clawdis-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b314ced8..541a99a78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,16 +11,21 @@ - `skillsInstall.*` → `skills.install.*` - per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`) - new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills) +- Sessions: group keys now use `surface:group:` / `surface:channel:`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized. +- Discord: remove legacy `discord.allowFrom`, `discord.guildAllowFrom`, and `discord.requireMention`; use `discord.dm` + `discord.guilds`. ### Features - Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. - UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android). - Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward). - Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`). +- Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching. - Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider. - iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support. - Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI. - Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context. +- Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off). +- Skills: add Trello skill for board/list/card management (thanks @clawd). - Tests: add a Z.AI live test gate for smoke validation when keys are present. - macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs. - CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths. @@ -36,12 +41,15 @@ ### 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. - 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). +- 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. @@ -50,6 +58,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/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/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index 4beb1602c..4bb862486 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -23,7 +23,7 @@ public struct ClawdisChatView: View { static let composerPaddingHorizontal: CGFloat = 0 static let stackSpacing: CGFloat = 0 static let messageSpacing: CGFloat = 6 - static let messageListPaddingTop: CGFloat = 0 + static let messageListPaddingTop: CGFloat = 12 static let messageListPaddingBottom: CGFloat = 16 static let messageListPaddingHorizontal: CGFloat = 6 #else @@ -32,7 +32,7 @@ public struct ClawdisChatView: View { static let composerPaddingHorizontal: CGFloat = 6 static let stackSpacing: CGFloat = 6 static let messageSpacing: CGFloat = 12 - static let messageListPaddingTop: CGFloat = 4 + static let messageListPaddingTop: CGFloat = 10 static let messageListPaddingBottom: CGFloat = 6 static let messageListPaddingHorizontal: CGFloat = 8 #endif diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift index 025e91ca6..010f56046 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift @@ -341,6 +341,11 @@ public final class ClawdisChatViewModel { ClawdisChatSessionEntry( key: key, kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: nil, sessionId: nil, systemSent: nil, diff --git a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift index acdcd7a6c..9b2a7cfa6 100644 --- a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift +++ b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift @@ -282,6 +282,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "recent-1", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: recent, sessionId: nil, systemSent: nil, @@ -296,6 +301,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "main", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: stale, sessionId: nil, systemSent: nil, @@ -310,6 +320,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "recent-2", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: recentOlder, sessionId: nil, systemSent: nil, @@ -324,6 +339,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "old-1", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: stale, sessionId: nil, systemSent: nil, @@ -365,6 +385,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "main", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: recent, sessionId: nil, systemSent: nil, diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index 4c967fd16..ddf2d0ea1 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -83,7 +83,7 @@ git commit -m "Add Clawd workspace" ## What Clawdis Does - Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac. - macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdis` CLI via its bundled binary. -- Direct chats collapse into the shared `main` session by default; groups stay isolated as `group:`; heartbeats keep background tasks alive. +- Direct chats collapse into the shared `main` session by default; groups stay isolated as `surface:group:` (rooms: `surface:channel:`); heartbeats keep background tasks alive. ## Core Skills (enable in Settings → Skills) - **mcporter** — Tool server runtime/CLI for managing external skill backends. diff --git a/docs/configuration.md b/docs/configuration.md index 421bf6a04..8564e98ce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,20 +173,32 @@ 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) + groupEnabled: false, // enable group DMs + groupChannels: ["clawd-dm"] // optional group DM allowlist + }, + guilds: { + "123456789012345678": { // guild id (preferred) or slug + slug: "friends-of-clawd", + requireMention: false, // per-guild default + users: ["987654321098765432"], // optional per-guild user allowlist + channels: { + general: { allow: true }, + help: { allow: true, requireMention: true } + } + } + }, + historyLimit: 20 // include last N guild messages as context } } ``` Clawdis reads `DISCORD_BOT_TOKEN` or `discord.token` to start the provider (unless `discord.enabled` is `false`). Use `user:` (DM) or `channel:` (guild channel) when specifying delivery targets for cron/CLI commands. +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 475525aa6..da3e4658c 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:` (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 @@ -20,13 +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.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. +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). @@ -41,22 +43,38 @@ 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"], + groupEnabled: false, + groupChannels: ["clawd-dm"] + }, + guilds: { + "123456789012345678": { + slug: "friends-of-clawd", + requireMention: false, + users: ["987654321098765432", "steipete"], + channels: { + general: { allow: true }, + help: { allow: true, requireMention: true } + } + } + } } } ``` -- `allowFrom`: DM allowlist (user ids). Omit or set to `["*"]` to allow any DM sender. -- `guildAllowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids). When both are set, both must match. -- `requireMention`: when `true`, messages in guild channels must mention the bot. +- `dm.enabled`: set `false` to ignore all DMs (default `true`). +- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender. +- `dm.groupEnabled`: enable group DMs (default `false`). +- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs. +- `guilds`: per-guild rules keyed by guild id (preferred) or slug. +- `guilds..slug`: optional friendly slug used for display names. +- `guilds..users`: optional per-guild user allowlist (ids or names). +- `guilds..channels`: channel rules (keys are channel slugs or ids). +- `guilds..requireMention`: per-guild mention requirement (overridable per channel). - `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`). 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..8430819fa 100644 --- a/docs/session.md +++ b/docs/session.md @@ -18,12 +18,14 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli - Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`). - Transcripts: `~/.clawdis/sessions/.jsonl` (one file per session id). - The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand. +- Group entries may include `displayName`, `surface`, `subject`, `room`, and `space` to label sessions in UIs. - Clawdis does **not** read legacy Pi/Tau session folders. ## Mapping transports → session keys - Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context. - Multiple phone numbers can map to that same key; they act as transports into the same conversation. -- Group chats still isolate state with `group:` keys; do not reuse the primary key for groups. +- Group chats isolate state with `surface:group:` keys (rooms/channels use `surface:channel:`); do not reuse the primary key for groups. (Discord display names show `discord:#`.) + - Legacy `group::` and `group:` keys are still recognized. ## Lifecyle - Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message. diff --git a/docs/sessions.md b/docs/sessions.md new file mode 100644 index 000000000..56627b95a --- /dev/null +++ b/docs/sessions.md @@ -0,0 +1,8 @@ +--- +summary: "Alias for session management docs" +read_when: + - You looked for docs/sessions.md; canonical doc lives in docs/session.md +--- +# Sessions + +Canonical session management docs live in `docs/session.md`. diff --git a/docs/signal.md b/docs/signal.md index cc6898dbe..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/skills/trello/SKILL.md b/skills/trello/SKILL.md new file mode 100644 index 000000000..4ddd421d2 --- /dev/null +++ b/skills/trello/SKILL.md @@ -0,0 +1,84 @@ +--- +name: trello +description: Manage Trello boards, lists, and cards via the Trello REST API. +homepage: https://developer.atlassian.com/cloud/trello/rest/ +metadata: {"clawdis":{"emoji":"📋","requires":{"bins":["jq"],"env":["TRELLO_API_KEY","TRELLO_TOKEN"]}}} +--- + +# Trello Skill + +Manage Trello boards, lists, and cards directly from Clawdis. + +## Setup + +1. Get your API key: https://trello.com/app-key +2. Generate a token (click "Token" link on that page) +3. Set environment variables: + ```bash + export TRELLO_API_KEY="your-api-key" + export TRELLO_TOKEN="your-token" + ``` + +## Usage + +All commands use curl to hit the Trello REST API. + +### List boards +```bash +curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}' +``` + +### List lists in a board +```bash +curl -s "https://api.trello.com/1/boards/{boardId}/lists?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id}' +``` + +### List cards in a list +```bash +curl -s "https://api.trello.com/1/lists/{listId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, id, desc}' +``` + +### Create a card +```bash +curl -s -X POST "https://api.trello.com/1/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \ + -d "idList={listId}" \ + -d "name=Card Title" \ + -d "desc=Card description" +``` + +### Move a card to another list +```bash +curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \ + -d "idList={newListId}" +``` + +### Add a comment to a card +```bash +curl -s -X POST "https://api.trello.com/1/cards/{cardId}/actions/comments?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \ + -d "text=Your comment here" +``` + +### Archive a card +```bash +curl -s -X PUT "https://api.trello.com/1/cards/{cardId}?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" \ + -d "closed=true" +``` + +## Notes + +- Board/List/Card IDs can be found in the Trello URL or via the list commands +- The API key and token provide full access to your Trello account - keep them secret! +- Rate limits: 300 requests per 10 seconds per API key; 100 requests per 10 seconds per token; `/1/members` endpoints are limited to 100 requests per 900 seconds + +## Examples + +```bash +# Get all boards +curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN&fields=name,id" | jq + +# Find a specific board by name +curl -s "https://api.trello.com/1/members/me/boards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | select(.name | contains("Work"))' + +# Get all cards on a board +curl -s "https://api.trello.com/1/boards/{boardId}/cards?key=$TRELLO_API_KEY&token=$TRELLO_TOKEN" | jq '.[] | {name, list: .idList}' +``` diff --git a/src/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..fff9a5158 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,41 @@ export async function getReplyFromConfig( modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, queueMode: baseEntry?.queueMode, + displayName: baseEntry?.displayName, + chatType: baseEntry?.chatType, + surface: baseEntry?.surface, + subject: baseEntry?.subject, + room: baseEntry?.room, + space: baseEntry?.space, }; + if (groupResolution?.surface) { + const surface = groupResolution.surface; + const subject = ctx.GroupSubject?.trim(); + const space = ctx.GroupSpace?.trim(); + const explicitRoom = ctx.GroupRoom?.trim(); + const isRoomSurface = surface === "discord" || surface === "slack"; + const nextRoom = + explicitRoom ?? + (isRoomSurface && subject && subject.startsWith("#") + ? subject + : undefined); + const nextSubject = nextRoom ? undefined : subject; + sessionEntry.chatType = groupResolution.chatType ?? "group"; + sessionEntry.surface = surface; + if (nextSubject) sessionEntry.subject = nextSubject; + if (nextRoom) sessionEntry.room = nextRoom; + if (space) sessionEntry.space = space; + sessionEntry.displayName = buildGroupDisplayName({ + surface: sessionEntry.surface, + subject: sessionEntry.subject, + room: sessionEntry.room, + space: sessionEntry.space, + id: groupResolution.id, + key: sessionKey, + }); + } else if (!sessionEntry.chatType) { + sessionEntry.chatType = "direct"; + } sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); @@ -1038,8 +1084,7 @@ export async function getReplyFromConfig( // Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot. // Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact. const isGroupSession = - typeof ctx.From === "string" && - (ctx.From.includes("@g.us") || ctx.From.startsWith("group:")); + sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room"; const isMainSession = !isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main"); if (isMainSession) { diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 0ca8852ef..249fc6277 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -63,8 +63,9 @@ describe("buildStatusMessage", () => { sessionId: "g1", updatedAt: 0, groupActivation: "always", + chatType: "group", }, - sessionKey: "group:123@g.us", + sessionKey: "whatsapp:group:123@g.us", sessionScope: "per-sender", webLinked: true, }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index dbef495d2..4024841ef 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -191,7 +191,13 @@ export function buildStatusMessage(args: StatusArgs): string { .filter(Boolean) .join(" • "); - const groupActivationLine = args.sessionKey?.startsWith("group:") + const isGroupSession = + entry?.chatType === "group" || + entry?.chatType === "room" || + Boolean(args.sessionKey?.includes(":group:")) || + Boolean(args.sessionKey?.includes(":channel:")) || + Boolean(args.sessionKey?.startsWith("group:")); + const groupActivationLine = isGroupSession ? `Group activation: ${entry?.groupActivation ?? "mention"}` : undefined; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 00dc4a9a7..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/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/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/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")) { 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/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/config.ts b/src/config/config.ts index 8aea48a51..b4d1161c7 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -164,21 +164,40 @@ export type TelegramConfig = { webhookPath?: string; }; +export type DiscordDmConfig = { + /** If false, ignore all incoming Discord DMs. Default: true. */ + enabled?: boolean; + /** Allowlist for DM senders (ids or names). */ + allowFrom?: Array; + /** If true, allow group DMs (default: false). */ + groupEnabled?: boolean; + /** Optional allowlist for group DM channels (ids or slugs). */ + groupChannels?: Array; +}; + +export type DiscordGuildChannelConfig = { + allow?: boolean; + requireMention?: boolean; +}; + +export type DiscordGuildEntry = { + slug?: string; + requireMention?: boolean; + users?: Array; + channels?: Record; +}; + export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; token?: string; - allowFrom?: Array; - guildAllowFrom?: { - guilds?: Array; - users?: Array; - }; - requireMention?: boolean; mediaMaxMb?: number; - /** Number of recent guild messages to include for context (default: 20). */ historyLimit?: number; /** Allow agent-triggered Discord reactions (default: true). */ enableReactions?: boolean; + dm?: DiscordDmConfig; + /** New per-guild config keyed by guild id or slug. */ + guilds?: Record; }; export type SignalConfig = { @@ -908,17 +927,53 @@ 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(), + dm: z + .object({ + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupEnabled: z.boolean().optional(), + groupChannels: z.array(z.union([z.string(), z.number()])).optional(), + }) + .optional(), + guilds: z + .record( + z.string(), + z + .object({ + slug: z.string().optional(), + requireMention: z.boolean().optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + channels: z + .record( + z.string(), + z + .object({ + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), + }) + .optional(), + ) + .optional(), + guild: z + .object({ + allowFrom: z + .object({ + guilds: z.array(z.union([z.string(), z.number()])).optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + }) + .optional(), + channels: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional(), + historyLimit: z.number().int().min(0).optional(), + }) + .optional(), }) .optional(), signal: z diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index dcab9cb14..6422f0b45 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + buildGroupDisplayName, deriveSessionKey, loadSessionStore, resolveSessionKey, @@ -31,6 +32,38 @@ describe("sessions", () => { ); }); + it("prefixes group keys with surface when available", () => { + expect( + deriveSessionKey("per-sender", { + From: "12345-678@g.us", + ChatType: "group", + Surface: "whatsapp", + }), + ).toBe("whatsapp:group:12345-678@g.us"); + }); + + it("keeps explicit surface when provided in group key", () => { + expect( + resolveSessionKey( + "per-sender", + { From: "group:discord:12345", ChatType: "group" }, + "main", + ), + ).toBe("discord:group:12345"); + }); + + it("builds discord display name with guild+channel slugs", () => { + expect( + buildGroupDisplayName({ + surface: "discord", + room: "#general", + space: "friends-of-clawd", + id: "123", + key: "discord:group:123", + }), + ).toBe("discord:friends-of-clawd#general"); + }); + it("collapses direct chats to main by default", () => { expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main"); }); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 449638101..f96de2cf9 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,142 @@ export function resolveStorePath(store?: string) { return path.resolve(store); } +function normalizeGroupLabel(raw?: string) { + const trimmed = raw?.trim().toLowerCase() ?? ""; + if (!trimmed) return ""; + const dashed = trimmed.replace(/\s+/g, "-"); + const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-"); + return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); +} + +function shortenGroupId(value?: string) { + const trimmed = value?.trim() ?? ""; + if (!trimmed) return ""; + if (trimmed.length <= 14) return trimmed; + return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`; +} + +export function buildGroupDisplayName(params: { + surface?: string; + subject?: string; + room?: string; + space?: string; + id?: string; + key: string; +}) { + const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim(); + const room = params.room?.trim(); + const space = params.space?.trim(); + const subject = params.subject?.trim(); + const detail = + (room && space + ? `${space}${room.startsWith("#") ? "" : "#"}${room}` + : room || subject || space || "") || ""; + const fallbackId = params.id?.trim() || params.key.replace(/^group:/, ""); + const rawLabel = detail || fallbackId; + let token = normalizeGroupLabel(rawLabel); + if (!token) { + token = normalizeGroupLabel(shortenGroupId(rawLabel)); + } + if (!params.room && token.startsWith("#")) { + token = token.replace(/^#+/, ""); + } + if ( + token && + !/^[@#]/.test(token) && + !token.startsWith("g-") && + !token.includes("#") + ) { + token = `g-${token}`; + } + return token ? `${surfaceKey}:${token}` : surfaceKey; +} + +export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null { + const from = typeof ctx.From === "string" ? ctx.From.trim() : ""; + if (!from) return null; + const chatType = ctx.ChatType?.trim().toLowerCase(); + const isGroup = + chatType === "group" || + from.startsWith("group:") || + from.includes("@g.us") || + from.includes(":group:") || + from.includes(":channel:"); + if (!isGroup) return null; + + const surfaceHint = ctx.Surface?.trim().toLowerCase(); + const hasLegacyGroupPrefix = from.startsWith("group:"); + const raw = (hasLegacyGroupPrefix ? from.slice("group:".length) : from).trim(); + + let surface: string | undefined; + let kind: "group" | "channel" | undefined; + let id = ""; + + const parseKind = (value: string) => { + if (value === "channel") return "channel"; + return "group"; + }; + + const parseParts = (parts: string[]) => { + if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) { + surface = parts[0]; + if (parts.length >= 3) { + const kindCandidate = parts[1]; + if (["group", "channel"].includes(kindCandidate)) { + kind = parseKind(kindCandidate); + id = parts.slice(2).join(":"); + } else { + id = parts.slice(1).join(":"); + } + } else { + id = parts[1]; + } + return; + } + if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) { + kind = parseKind(parts[0]); + id = parts.slice(1).join(":"); + } + }; + + if (hasLegacyGroupPrefix) { + const legacyParts = raw.split(":").filter(Boolean); + if (legacyParts.length > 1) { + parseParts(legacyParts); + } else { + id = raw; + } + } else if (from.includes("@g.us") && !from.includes(":")) { + id = from; + } else { + parseParts(from.split(":").filter(Boolean)); + if (!id) { + id = raw || from; + } + } + + const resolvedSurface = surface ?? surfaceHint; + if (!resolvedSurface) { + const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`; + return { key: legacy, id: raw || from, legacyKey: legacy, chatType: "group" }; + } + + const resolvedKind = kind === "channel" ? "channel" : "group"; + const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`; + let legacyKey: string | undefined; + if (hasLegacyGroupPrefix || from.includes("@g.us")) { + legacyKey = `group:${id || raw || from}`; + } + + return { + key, + legacyKey, + surface: resolvedSurface, + id: id || raw || from, + chatType: resolvedKind === "channel" ? "room" : "group", + }; +} + export function loadSessionStore( storePath: string, ): Record { @@ -145,6 +307,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 +325,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 +344,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.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 42c459aae..08b376597 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,4 +1,5 @@ import { + ChannelType, Client, Events, GatewayIntentBits, @@ -24,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; }; @@ -47,6 +42,25 @@ type DiscordHistoryEntry = { messageId?: string; }; +export type DiscordAllowList = { + allowAll: boolean; + ids: Set; + names: Set; +}; + +export type DiscordGuildEntryResolved = { + id?: string; + slug?: string; + requireMention?: boolean; + users?: Array; + channels?: Record; +}; + +export type DiscordChannelConfigResolved = { + allowed: boolean; + requireMention?: boolean; +}; + export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = loadConfig(); const token = normalizeDiscordToken( @@ -69,16 +83,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }, }; - const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom; - const guildAllowFrom = opts.guildAllowFrom ?? cfg.discord?.guildAllowFrom; - const requireMention = - opts.requireMention ?? cfg.discord?.requireMention ?? true; + const dmConfig = cfg.discord?.dm; + const guildEntries = cfg.discord?.guilds; + const allowFrom = dmConfig?.allowFrom; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const historyLimit = Math.max( 0, opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, ); + const dmEnabled = dmConfig?.enabled ?? true; + const groupDmEnabled = dmConfig?.groupEnabled ?? false; + const groupDmChannels = dmConfig?.groupChannels; const client = new Client({ intents: [ @@ -106,7 +122,12 @@ 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); + if (isGroupDm && !groupDmEnabled) return; + if (isDirectMessage && !dmEnabled) return; const botId = client.user?.id; const wasMentioned = !isDirectMessage && Boolean(botId && message.mentions.has(botId)); @@ -117,7 +138,59 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { message.embeds[0]?.description || ""; - if (!isDirectMessage && historyLimit > 0 && baseText) { + const guildInfo = isGuildMessage + ? resolveDiscordGuildEntry({ + guild: message.guild, + guildEntries, + }) + : null; + if ( + isGuildMessage && + guildEntries && + Object.keys(guildEntries).length > 0 && + !guildInfo + ) { + logVerbose( + `Blocked discord guild ${message.guild?.id ?? "unknown"} (not in discord.guilds)`, + ); + return; + } + + const channelName = + (isGuildMessage || isGroupDm) && "name" in message.channel + ? message.channel.name + : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const guildSlug = + guildInfo?.slug || + (message.guild?.name ? normalizeDiscordSlug(message.guild.name) : ""); + const channelConfig = isGuildMessage + ? resolveDiscordChannelConfig({ + guildInfo, + channelId: message.channelId, + channelName, + channelSlug, + }) + : null; + + const groupDmAllowed = + isGroupDm && + resolveGroupDmAllow({ + channels: groupDmChannels, + channelId: message.channelId, + channelName, + channelSlug, + }); + if (isGroupDm && !groupDmAllowed) return; + + if (isGuildMessage && channelConfig?.allowed === false) { + logVerbose( + `Blocked discord channel ${message.channelId} not in guild channel allowlist`, + ); + return; + } + + if (isGuildMessage && historyLimit > 0 && baseText) { const history = guildHistories.get(message.channelId) ?? []; history.push({ sender: message.member?.displayName ?? message.author.tag, @@ -129,7 +202,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { guildHistories.set(message.channelId, history); } - if (!isDirectMessage && requireMention) { + const resolvedRequireMention = + channelConfig?.requireMention ?? guildInfo?.requireMention ?? true; + if (isGuildMessage && resolvedRequireMention) { if (botId && !wasMentioned) { logger.info( { @@ -142,46 +217,45 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } } - if (!isDirectMessage && guildAllowFrom) { - const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [ - "guild:", - ]); - const users = normalizeDiscordAllowList(guildAllowFrom.users, [ - "discord:", - "user:", - ]); - if (guilds || users) { - const guildId = message.guild?.id ?? ""; - const userId = message.author.id; - const guildOk = - !guilds || guilds.allowAll || (guildId && guilds.ids.has(guildId)); - const userOk = !users || users.allowAll || users.ids.has(userId); - if (!guildOk || !userOk) { + if (isGuildMessage) { + const userAllow = guildInfo?.users; + if (Array.isArray(userAllow) && userAllow.length > 0) { + const users = normalizeDiscordAllowList(userAllow, [ + "discord:", + "user:", + ]); + const userOk = + !users || + allowListMatches(users, { + id: message.author.id, + name: message.author.username, + tag: message.author.tag, + }); + if (!userOk) { logVerbose( - `Blocked discord guild sender ${userId} (guild ${guildId || "unknown"}) not in guildAllowFrom`, + `Blocked discord guild sender ${message.author.id} (not in guild users allowlist)`, ); return; } } + } if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) { - const allowed = allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean); - const candidate = message.author.id; - const normalized = new Set( - allowed - .filter((entry) => entry !== "*") - .map((entry) => entry.replace(/^discord:/i, "")), - ); + const allowList = normalizeDiscordAllowList(allowFrom, [ + "discord:", + "user:", + ]); const permitted = - allowed.includes("*") || - normalized.has(candidate) || - allowed.includes(candidate); + allowList && + allowListMatches(allowList, { + id: message.author.id, + name: message.author.username, + tag: message.author.tag, + }); if (!permitted) { logVerbose( - `Blocked unauthorized discord sender ${candidate} (not in allowFrom)`, + `Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`, ); return; } @@ -198,6 +272,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const fromLabel = isDirectMessage ? buildDirectLabel(message) : buildGuildLabel(message); + const groupRoom = + isGuildMessage && channelSlug ? `#${channelSlug}` : undefined; + const groupSubject = isDirectMessage ? undefined : groupRoom; const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`; let combinedBody = formatAgentEnvelope({ surface: "Discord", @@ -240,10 +317,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { : `channel:${message.channelId}`, ChatType: isDirectMessage ? "direct" : "group", SenderName: message.member?.displayName ?? message.author.tag, - GroupSubject: - !isDirectMessage && "name" in message.channel - ? message.channel.name - : undefined, + GroupSubject: groupSubject, + GroupRoom: groupRoom, + GroupSpace: isGuildMessage ? guildSlug || undefined : undefined, Surface: "discord" as const, WasMentioned: wasMentioned, MessageSid: message.id, @@ -292,7 +368,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) { @@ -366,25 +442,174 @@ function buildGuildLabel(message: import("discord.js").Message) { return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`; } -function normalizeDiscordAllowList( +export function normalizeDiscordAllowList( raw: Array | undefined, prefixes: string[], -): { allowAll: boolean; ids: Set } | null { +): DiscordAllowList | null { if (!raw || raw.length === 0) return null; - const cleaned = raw - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => { - for (const prefix of prefixes) { - if (entry.toLowerCase().startsWith(prefix)) { - return entry.slice(prefix.length); - } + const ids = new Set(); + const names = new Set(); + let allowAll = false; + + for (const rawEntry of raw) { + let entry = String(rawEntry).trim(); + if (!entry) continue; + if (entry === "*") { + allowAll = true; + continue; + } + for (const prefix of prefixes) { + if (entry.toLowerCase().startsWith(prefix)) { + entry = entry.slice(prefix.length); + break; } - return entry; - }); - const 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); + const slugged = normalizeDiscordSlug(entry); + if (slugged) names.add(slugged); + } + + if (!allowAll && ids.size === 0 && names.size === 0) return null; + return { allowAll, ids, names }; +} + +function normalizeDiscordName(value?: string | null) { + if (!value) return ""; + return value.trim().toLowerCase(); +} + +export function normalizeDiscordSlug(value?: string | null) { + if (!value) return ""; + let text = value.trim().toLowerCase(); + if (!text) return ""; + text = text.replace(/^[@#]+/, ""); + text = text.replace(/[\s_]+/g, "-"); + text = text.replace(/[^a-z0-9-]+/g, "-"); + text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, ""); + return text; +} + +export function allowListMatches( + allowList: DiscordAllowList, + candidates: { + id?: string; + name?: string | null; + tag?: string | null; + }, +) { + if (allowList.allowAll) return true; + const { id, name, tag } = candidates; + if (id && allowList.ids.has(id)) return true; + const normalizedName = normalizeDiscordName(name); + if (normalizedName && allowList.names.has(normalizedName)) return true; + const normalizedTag = normalizeDiscordName(tag); + if (normalizedTag && allowList.names.has(normalizedTag)) return true; + const slugName = normalizeDiscordSlug(name); + if (slugName && allowList.names.has(slugName)) return true; + const slugTag = normalizeDiscordSlug(tag); + if (slugTag && allowList.names.has(slugTag)) return true; + return false; +} + +export function resolveDiscordGuildEntry(params: { + guild: import("discord.js").Guild | null; + guildEntries: Record | undefined; +}): DiscordGuildEntryResolved | null { + const { guild, guildEntries } = params; + if (!guild || !guildEntries || Object.keys(guildEntries).length === 0) { + return null; + } + const guildId = guild.id; + const guildSlug = normalizeDiscordSlug(guild.name); + const direct = guildEntries[guildId]; + if (direct) { + return { + id: guildId, + slug: direct.slug ?? guildSlug, + requireMention: direct.requireMention, + users: direct.users, + channels: direct.channels, + }; + } + if (guildSlug && guildEntries[guildSlug]) { + const entry = guildEntries[guildSlug]; + return { + id: guildId, + slug: entry.slug ?? guildSlug, + requireMention: entry.requireMention, + users: entry.users, + channels: entry.channels, + }; + } + const matchBySlug = Object.entries(guildEntries).find(([, entry]) => { + const entrySlug = normalizeDiscordSlug(entry.slug); + return entrySlug && entrySlug === guildSlug; + }); + if (matchBySlug) { + const entry = matchBySlug[1]; + return { + id: guildId, + slug: entry.slug ?? guildSlug, + requireMention: entry.requireMention, + users: entry.users, + channels: entry.channels, + }; + } + return null; +} + +export function resolveDiscordChannelConfig(params: { + guildInfo: DiscordGuildEntryResolved | null; + channelId: string; + channelName?: string; + channelSlug?: string; +}): DiscordChannelConfigResolved | null { + const { guildInfo, channelId, channelName, channelSlug } = params; + const channelEntries = guildInfo?.channels; + if (channelEntries && Object.keys(channelEntries).length > 0) { + const entry = + channelEntries[channelId] ?? + (channelSlug + ? channelEntries[channelSlug] ?? + channelEntries[`#${channelSlug}`] + : undefined) ?? + (channelName + ? channelEntries[normalizeDiscordSlug(channelName)] + : undefined); + if (!entry) return { allowed: false }; + return { allowed: entry.allow !== false, requireMention: entry.requireMention }; + } + return { allowed: true }; +} + +export function resolveGroupDmAllow(params: { + channels: Array | undefined; + channelId: string; + channelName?: string; + channelSlug?: string; +}) { + const { channels, channelId, channelName, channelSlug } = params; + if (!channels || channels.length === 0) return true; + const allowList = normalizeDiscordAllowList(channels, ["channel:"]); + if (!allowList) return true; + return allowListMatches(allowList, { + id: channelId, + name: channelSlug || channelName, + }); } async function sendTyping(message: Message) { 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; 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..73c11f5be 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, @@ -2153,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, }) @@ -2881,6 +2935,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/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; 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)}