diff --git a/CHANGELOG.md b/CHANGELOG.md index 5385b7c70..3485c1157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - `skillsInstall.*` → `skills.install.*` - per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`) - new optional bundled allowlist: `skills.allowBundled` (only affects bundled skills) +- Sessions: group keys now use `surface:group:` / `surface:channel:`; legacy `group:*` keys migrate on next message; `groupdm` keys are no longer recognized. ### Features - Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt index d37e9ff46..1e7a23dcf 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatController.kt @@ -469,7 +469,8 @@ class ChatController( val key = obj["key"].asStringOrNull()?.trim().orEmpty() if (key.isEmpty()) return@mapNotNull null val updatedAt = obj["updatedAt"].asLongOrNull() - ChatSessionEntry(key = key, updatedAtMs = updatedAt) + val displayName = obj["displayName"].asStringOrNull()?.trim() + ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName) } } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt index 2d27a5be4..8c7f1b3b3 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/chat/ChatModels.kt @@ -25,6 +25,7 @@ data class ChatPendingToolCall( data class ChatSessionEntry( val key: String, val updatedAtMs: Long?, + val displayName: String? = null, ) data class ChatHistory( diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt index af958d71c..8ac6f0aca 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt @@ -62,6 +62,8 @@ fun ChatComposer( var showSessionMenu by remember { mutableStateOf(false) } val sessionOptions = resolveSessionChoices(sessionKey, sessions) + val currentSessionLabel = + sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk @@ -82,13 +84,13 @@ fun ChatComposer( onClick = { showSessionMenu = true }, contentPadding = ButtonDefaults.ContentPadding, ) { - Text("Session: $sessionKey") + Text("Session: $currentSessionLabel") } DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) { for (entry in sessionOptions) { DropdownMenuItem( - text = { Text(entry.key) }, + text = { Text(entry.displayName ?: entry.key) }, onClick = { onSelectSession(entry.key) showSessionMenu = false diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt index 55d30b9b7..218cb25f6 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSessionsDialog.kt @@ -82,7 +82,7 @@ private fun SessionRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Text(entry.key, style = MaterialTheme.typography.bodyMedium) + Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.weight(1f)) if (isCurrent) { Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -90,4 +90,3 @@ private fun SessionRow( } } } - diff --git a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift index c54c4a067..41005e826 100644 --- a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift +++ b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift @@ -79,7 +79,7 @@ struct ContextMenuCardView: View { height: self.barHeight) HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(row.key) + Text(row.label) .font(.caption.weight(row.key == "main" ? .semibold : .regular)) .lineLimit(1) .truncationMode(.middle) diff --git a/apps/macos/Sources/Clawdis/SessionData.swift b/apps/macos/Sources/Clawdis/SessionData.swift index ebfbaac7f..ce0501209 100644 --- a/apps/macos/Sources/Clawdis/SessionData.swift +++ b/apps/macos/Sources/Clawdis/SessionData.swift @@ -8,6 +8,11 @@ struct GatewaySessionDefaultsRecord: Codable { struct GatewaySessionEntryRecord: Codable { let key: String + let displayName: String? + let surface: String? + let subject: String? + let room: String? + let space: String? let updatedAt: Double? let sessionId: String? let systemSent: Bool? @@ -65,6 +70,11 @@ struct SessionRow: Identifiable { let id: String let key: String let kind: SessionKind + let displayName: String? + let surface: String? + let subject: String? + let room: String? + let space: String? let updatedAt: Date? let sessionId: String? let thinkingLevel: String? @@ -75,6 +85,7 @@ struct SessionRow: Identifiable { let model: String? var ageText: String { relativeAge(from: self.updatedAt) } + var label: String { self.displayName ?? self.key } var flagLabels: [String] { var flags: [String] = [] @@ -92,6 +103,8 @@ enum SessionKind { static func from(key: String) -> SessionKind { if key == "global" { return .global } if key.hasPrefix("group:") { return .group } + if key.contains(":group:") { return .group } + if key.contains(":channel:") { return .group } if key == "unknown" { return .unknown } return .direct } @@ -127,6 +140,11 @@ extension SessionRow { id: "direct-1", key: "user@example.com", kind: .direct, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date().addingTimeInterval(-90), sessionId: "sess-direct-1234", thinkingLevel: "low", @@ -137,8 +155,13 @@ extension SessionRow { model: "claude-3.5-sonnet"), SessionRow( id: "group-1", - key: "group:engineering", + key: "discord:channel:release-squad", kind: .group, + displayName: "discord:#release-squad", + surface: "discord", + subject: nil, + room: "#release-squad", + space: nil, updatedAt: Date().addingTimeInterval(-3600), sessionId: "sess-group-4321", thinkingLevel: "medium", @@ -151,6 +174,11 @@ extension SessionRow { id: "global", key: "global", kind: .global, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date().addingTimeInterval(-86400), sessionId: nil, thinkingLevel: nil, @@ -269,6 +297,11 @@ enum SessionLoader { id: entry.key, key: entry.key, kind: SessionKind.from(key: entry.key), + displayName: entry.displayName, + surface: entry.surface, + subject: entry.subject, + room: entry.room, + space: entry.space, updatedAt: updated, sessionId: entry.sessionId, thinkingLevel: entry.thinkingLevel, diff --git a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift index 5cfd1b4a0..7d1ad5ca8 100644 --- a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift +++ b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift @@ -36,7 +36,7 @@ struct SessionMenuLabelView: View { height: self.barHeight) HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(self.row.key) + Text(self.row.label) .font(.caption.weight(self.row.key == "main" ? .semibold : .regular)) .foregroundStyle(self.primaryTextColor) .lineLimit(1) diff --git a/apps/macos/Sources/Clawdis/SessionsSettings.swift b/apps/macos/Sources/Clawdis/SessionsSettings.swift index 88e87eea6..4a2a0e81e 100644 --- a/apps/macos/Sources/Clawdis/SessionsSettings.swift +++ b/apps/macos/Sources/Clawdis/SessionsSettings.swift @@ -89,7 +89,7 @@ struct SessionsSettings: View { private func sessionRow(_ row: SessionRow) -> some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(row.key) + Text(row.label) .font(.subheadline.bold()) .lineLimit(1) .truncationMode(.middle) diff --git a/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift index cfd08d03b..91048ec87 100644 --- a/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/MenuSessionsInjectorTests.swift @@ -29,6 +29,11 @@ struct MenuSessionsInjectorTests { id: "main", key: "main", kind: .direct, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date(), sessionId: "s1", thinkingLevel: "low", @@ -38,9 +43,14 @@ struct MenuSessionsInjectorTests { tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000), model: "claude-opus-4-5"), SessionRow( - id: "group:alpha", - key: "group:alpha", + id: "discord:group:alpha", + key: "discord:group:alpha", kind: .group, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date(timeIntervalSinceNow: -60), sessionId: "s2", thinkingLevel: "high", diff --git a/apps/macos/Tests/ClawdisIPCTests/SessionDataTests.swift b/apps/macos/Tests/ClawdisIPCTests/SessionDataTests.swift index 8d8e36a54..d386558a5 100644 --- a/apps/macos/Tests/ClawdisIPCTests/SessionDataTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/SessionDataTests.swift @@ -6,7 +6,7 @@ import Testing struct SessionDataTests { @Test func sessionKindFromKeyDetectsCommonKinds() { #expect(SessionKind.from(key: "global") == .global) - #expect(SessionKind.from(key: "group:engineering") == .group) + #expect(SessionKind.from(key: "discord:group:engineering") == .group) #expect(SessionKind.from(key: "unknown") == .unknown) #expect(SessionKind.from(key: "user@example.com") == .direct) } @@ -27,6 +27,11 @@ struct SessionDataTests { id: "x", key: "user@example.com", kind: .direct, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: Date(), sessionId: nil, thinkingLevel: "high", @@ -41,4 +46,3 @@ struct SessionDataTests { #expect(row.flagLabels.contains("aborted")) } } - diff --git a/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift b/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift index a0d3a60d5..a50c616b2 100644 --- a/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/WorkActivityStoreTests.swift @@ -8,9 +8,9 @@ struct WorkActivityStoreTests { @Test func mainSessionJobPreemptsOther() { let store = WorkActivityStore() - store.handleJob(sessionKey: "group:1", state: "started") + store.handleJob(sessionKey: "discord:group:1", state: "started") #expect(store.iconState == .workingOther(.job)) - #expect(store.current?.sessionKey == "group:1") + #expect(store.current?.sessionKey == "discord:group:1") store.handleJob(sessionKey: "main", state: "started") #expect(store.iconState == .workingMain(.job)) @@ -18,9 +18,9 @@ struct WorkActivityStoreTests { store.handleJob(sessionKey: "main", state: "finished") #expect(store.iconState == .workingOther(.job)) - #expect(store.current?.sessionKey == "group:1") + #expect(store.current?.sessionKey == "discord:group:1") - store.handleJob(sessionKey: "group:1", state: "finished") + store.handleJob(sessionKey: "discord:group:1", state: "finished") #expect(store.iconState == .idle) #expect(store.current == nil) } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift index 2ea691c43..9bdd2bc93 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift @@ -103,7 +103,7 @@ struct ClawdisChatComposer: View { set: { next in self.viewModel.switchSession(to: next) })) { ForEach(self.viewModel.sessionChoices, id: \.key) { session in - Text(session.key) + Text(session.displayName ?? session.key) .font(.system(.caption, design: .monospaced)) .tag(session.key) } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift index 475528275..1db032523 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSessions.swift @@ -10,6 +10,11 @@ public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable public let key: String public let kind: String? + public let displayName: String? + public let surface: String? + public let subject: String? + public let room: String? + public let space: String? public let updatedAt: Double? public let sessionId: String? diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift index f413c3d3e..a4a8da6cc 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatSheets.swift @@ -14,7 +14,7 @@ struct ChatSessionsSheet: View { self.dismiss() } label: { VStack(alignment: .leading, spacing: 4) { - Text(session.key) + Text(session.displayName ?? session.key) .font(.system(.body, design: .monospaced)) .lineLimit(1) if let updatedAt = session.updatedAt, updatedAt > 0 { diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift index 025e91ca6..010f56046 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift @@ -341,6 +341,11 @@ public final class ClawdisChatViewModel { ClawdisChatSessionEntry( key: key, kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: nil, sessionId: nil, systemSent: nil, diff --git a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift index acdcd7a6c..9b2a7cfa6 100644 --- a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift +++ b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift @@ -282,6 +282,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "recent-1", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: recent, sessionId: nil, systemSent: nil, @@ -296,6 +301,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "main", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: stale, sessionId: nil, systemSent: nil, @@ -310,6 +320,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "recent-2", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: recentOlder, sessionId: nil, systemSent: nil, @@ -324,6 +339,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "old-1", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: stale, sessionId: nil, systemSent: nil, @@ -365,6 +385,11 @@ private extension TestChatTransportState { ClawdisChatSessionEntry( key: "main", kind: nil, + displayName: nil, + surface: nil, + subject: nil, + room: nil, + space: nil, updatedAt: recent, sessionId: nil, systemSent: nil, diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index 4c967fd16..ddf2d0ea1 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -83,7 +83,7 @@ git commit -m "Add Clawd workspace" ## What Clawdis Does - Runs WhatsApp gateway + Pi coding agent so the assistant can read/write chats, fetch context, and run skills via the host Mac. - macOS app manages permissions (screen recording, notifications, microphone) and exposes the `clawdis` CLI via its bundled binary. -- Direct chats collapse into the shared `main` session by default; groups stay isolated as `group:`; heartbeats keep background tasks alive. +- Direct chats collapse into the shared `main` session by default; groups stay isolated as `surface:group:` (rooms: `surface:channel:`); heartbeats keep background tasks alive. ## Core Skills (enable in Settings → Skills) - **mcporter** — Tool server runtime/CLI for managing external skill backends. diff --git a/docs/discord.md b/docs/discord.md index 475525aa6..fc56fc680 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -11,7 +11,8 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa ## Goals - Talk to Clawdis via Discord DMs or guild channels. -- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `group:`. +- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `discord:group:`. +- Group DMs are treated as group sessions (separate from `main`) and show up with a `discord:g-...` display label. - Keep routing deterministic: replies always go back to the surface they arrived on. ## How it works diff --git a/docs/grammy.md b/docs/grammy.md index 7214f40e7..cfd593622 100644 --- a/docs/grammy.md +++ b/docs/grammy.md @@ -17,7 +17,7 @@ Updated: 2025-12-07 - **Gateway:** `monitorTelegramProvider` builds a grammY `Bot`, wires mention/allowlist gating, media download via `getFile`/`download`, and delivers replies with `sendMessage/sendPhoto/sendVideo/sendAudio/sendDocument`. Supports long-poll or webhook via `webhookCallback`. - **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls). -- **Sessions:** direct chats map to `main`; groups map to `group:`; replies route back to the same surface. +- **Sessions:** direct chats map to `main`; groups map to `telegram:group:`; replies route back to the same surface. - **Config knobs:** `telegram.botToken`, `requireMention`, `allowFrom`, `mediaMaxMb`, `proxy`, `webhookSecret`, `webhookUrl`. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. diff --git a/docs/group-messages.md b/docs/group-messages.md index c50a5a5d0..94012b168 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -10,7 +10,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that ## What’s implemented (2025-12-03) - Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Activation is controlled per group (command or UI), not via config. - Group allowlist bypass: we still enforce `routing.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies. -- Per-group sessions: session keys look like `group:` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. +- Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. - Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. - Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. - Ephemeral/view-once: we unwrap those before extracting text/mentions, so pings inside them still trigger. @@ -63,4 +63,4 @@ Only the owner number (from `routing.allowFrom`, defaulting to the bot’s own E ## Known considerations - Heartbeats are intentionally skipped for groups to avoid noisy broadcasts. - Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response. -- Session store entries will appear as `group:` in the session store (`~/.clawdis/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet. +- Session store entries will appear as `whatsapp:group:` in the session store (`~/.clawdis/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet. diff --git a/docs/groups.md b/docs/groups.md index bc5aa96eb..febde1f18 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -8,10 +8,14 @@ read_when: Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage. ## Session keys -- Group sessions use `group:` in `ctx.From`. +- Group sessions use `surface:group:` session keys (rooms/channels use `surface:channel:`). - Direct chats use the main session (or per-sender if configured). - Heartbeats are skipped for group sessions. +## Display labels +- UI labels use `displayName` when available, formatted as `surface:`. +- `#room` is reserved for rooms/channels; group chats use `g-` (lowercase, spaces -> `-`, keep `#@+._-`). + ## Mention gating (default) Group messages require a mention unless overridden per group. diff --git a/docs/session.md b/docs/session.md index 756bd5181..ca78efed8 100644 --- a/docs/session.md +++ b/docs/session.md @@ -18,12 +18,14 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli - Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`). - Transcripts: `~/.clawdis/sessions/.jsonl` (one file per session id). - The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand. +- Group entries may include `displayName`, `surface`, `subject`, `room`, and `space` to label sessions in UIs. - Clawdis does **not** read legacy Pi/Tau session folders. ## Mapping transports → session keys - Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context. - Multiple phone numbers can map to that same key; they act as transports into the same conversation. -- Group chats still isolate state with `group:` keys; do not reuse the primary key for groups. +- Group chats isolate state with `surface:group:` keys (rooms/channels use `surface:channel:`); do not reuse the primary key for groups. + - Legacy `group::` and `group:` keys are still recognized. ## Lifecyle - Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message. diff --git a/docs/sessions.md b/docs/sessions.md new file mode 100644 index 000000000..56627b95a --- /dev/null +++ b/docs/sessions.md @@ -0,0 +1,8 @@ +--- +summary: "Alias for session management docs" +read_when: + - You looked for docs/sessions.md; canonical doc lives in docs/session.md +--- +# Sessions + +Canonical session management docs live in `docs/session.md`. diff --git a/docs/signal.md b/docs/signal.md index cc6898dbe..b386f9d6b 100644 --- a/docs/signal.md +++ b/docs/signal.md @@ -73,7 +73,7 @@ You can still run Clawdis on your own Signal account if your goal is “respond ## Addressing (send targets) - Direct: `signal:+15551234567` (or plain `+15551234567`) -- Groups: `group:` +- Groups: `signal:group:` - Usernames: `username:` / `u:` ## Process plan (Clawdis adapter) diff --git a/docs/surface.md b/docs/surface.md index 9135e80bc..e0cc05d9c 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -11,7 +11,7 @@ Goal: make replies deterministic per channel while keeping one shared context fo - **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `discord`, `imessage`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose. - **Reply context:** inbound replies include `ReplyToId`, `ReplyToBody`, and `ReplyToSender`, and the quoted context is appended to `Body` as a `[Replying to ...]` block. -- **Canonical direct session:** All direct chats collapse into the single `main` session by default (no config needed). Groups stay `group:`, so they remain isolated. +- **Canonical direct session:** All direct chats collapse into the single `main` session by default (no config needed). Groups stay `surface:group:` (rooms: `surface:channel:`), so they remain isolated. - **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdis/sessions/.jsonl`. - **WebChat:** Always attaches to `main`, loads the full session transcript so desktop reflects cross-surface history, and writes new turns back to the same session. - **Implementation hints:** diff --git a/docs/telegram.md b/docs/telegram.md index a30c135c4..06f298083 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -11,7 +11,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup ## Goals - Let you talk to Clawdis via a Telegram bot in DMs and groups. -- Share the same `main` session used by WhatsApp/WebChat; groups stay isolated as `group:`. +- Share the same `main` session used by WhatsApp/WebChat; groups stay isolated as `telegram:group:`. - Keep transport routing deterministic: replies always go back to the surface they arrived on. ## How it will work (Bot API) @@ -23,7 +23,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - The webhook listener currently binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default. - If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`. 4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config). -5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `group:` and require mention/command to trigger replies. +5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:` and require mention/command to trigger replies. 6) Optional allowlist: reuse `routing.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). ## Capabilities & limits (Bot API) diff --git a/docs/whatsapp.md b/docs/whatsapp.md index aa4b6a5b3..ee4a32837 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -52,7 +52,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. - `` ## Groups -- Groups map to `group:` sessions. +- Groups map to `whatsapp:group:` sessions. - Activation modes: - `mention` (default): requires @mention or regex match. - `always`: always triggers. diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 62c77995b..3d84067d7 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -220,6 +220,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", + Surface: "whatsapp", SenderE164: "+2000", }, {}, @@ -230,7 +231,7 @@ describe("trigger handling", () => { const store = JSON.parse( await fs.readFile(cfg.session.store, "utf-8"), ) as Record; - expect(store["group:123@g.us"]?.groupActivation).toBe("always"); + expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -244,6 +245,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", + Surface: "whatsapp", SenderE164: "+999", }, {}, @@ -270,6 +272,7 @@ describe("trigger handling", () => { From: "123@g.us", To: "+2000", ChatType: "group", + Surface: "whatsapp", SenderE164: "+2000", GroupSubject: "Test Group", GroupMembers: "Alice (+1), Bob (+2)", diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 916c16d4a..17bc067c7 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -29,7 +29,9 @@ import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, DEFAULT_RESET_TRIGGERS, + buildGroupDisplayName, loadSessionStore, + resolveGroupSessionKey, resolveSessionKey, resolveSessionTranscriptPath, resolveStorePath, @@ -364,9 +366,9 @@ export async function getReplyFromConfig( let persistedModelOverride: string | undefined; let persistedProviderOverride: string | undefined; + const groupResolution = resolveGroupSessionKey(ctx); const isGroup = - typeof ctx.From === "string" && - (ctx.From.includes("@g.us") || ctx.From.startsWith("group:")); + ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution); const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "") .trim() .toLowerCase(); @@ -399,6 +401,16 @@ export async function getReplyFromConfig( sessionKey = resolveSessionKey(sessionScope, ctx, mainKey); sessionStore = loadSessionStore(storePath); + if ( + groupResolution?.legacyKey && + groupResolution.legacyKey !== sessionKey + ) { + const legacyEntry = sessionStore[groupResolution.legacyKey]; + if (legacyEntry && !sessionStore[sessionKey]) { + sessionStore[sessionKey] = legacyEntry; + delete sessionStore[groupResolution.legacyKey]; + } + } const entry = sessionStore[sessionKey]; const idleMs = idleMinutes * 60_000; const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs; @@ -431,7 +443,35 @@ export async function getReplyFromConfig( modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, queueMode: baseEntry?.queueMode, + displayName: baseEntry?.displayName, + chatType: baseEntry?.chatType, + surface: baseEntry?.surface, + subject: baseEntry?.subject, + room: baseEntry?.room, + space: baseEntry?.space, }; + if (groupResolution?.surface) { + const surface = groupResolution.surface; + const subject = ctx.GroupSubject?.trim(); + const isRoomSurface = surface === "discord" || surface === "slack"; + const nextRoom = + isRoomSurface && subject && subject.startsWith("#") ? subject : undefined; + const nextSubject = nextRoom ? undefined : subject; + sessionEntry.chatType = groupResolution.chatType ?? "group"; + sessionEntry.surface = surface; + if (nextSubject) sessionEntry.subject = nextSubject; + if (nextRoom) sessionEntry.room = nextRoom; + sessionEntry.displayName = buildGroupDisplayName({ + surface: sessionEntry.surface, + subject: sessionEntry.subject, + room: sessionEntry.room, + space: sessionEntry.space, + id: groupResolution.id, + key: sessionKey, + }); + } else if (!sessionEntry.chatType) { + sessionEntry.chatType = "direct"; + } sessionStore[sessionKey] = sessionEntry; await saveSessionStore(storePath, sessionStore); @@ -1038,8 +1078,7 @@ export async function getReplyFromConfig( // Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot. // Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact. const isGroupSession = - typeof ctx.From === "string" && - (ctx.From.includes("@g.us") || ctx.From.startsWith("group:")); + sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room"; const isMainSession = !isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main"); if (isMainSession) { diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 0ca8852ef..249fc6277 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -63,8 +63,9 @@ describe("buildStatusMessage", () => { sessionId: "g1", updatedAt: 0, groupActivation: "always", + chatType: "group", }, - sessionKey: "group:123@g.us", + sessionKey: "whatsapp:group:123@g.us", sessionScope: "per-sender", webLinked: true, }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index dbef495d2..4024841ef 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -191,7 +191,13 @@ export function buildStatusMessage(args: StatusArgs): string { .filter(Boolean) .join(" • "); - const groupActivationLine = args.sessionKey?.startsWith("group:") + const isGroupSession = + entry?.chatType === "group" || + entry?.chatType === "room" || + Boolean(args.sessionKey?.includes(":group:")) || + Boolean(args.sessionKey?.includes(":channel:")) || + Boolean(args.sessionKey?.startsWith("group:")); + const groupActivationLine = isGroupSession ? `Group activation: ${entry?.groupActivation ?? "mention"}` : undefined; diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 5edd1c45d..64daf9a70 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -470,7 +470,7 @@ export async function agentCommand( } if (deliveryProvider === "signal" && !signalTarget) { const err = new Error( - "Delivering to Signal requires --to ", + "Delivering to Signal requires --to ", ); if (!bestEffortDeliver) throw err; logDeliveryError(err); diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 3d1fbd273..1ee0f8e68 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -77,7 +77,7 @@ describe("sessionsCommand", () => { it("shows placeholder rows when tokens are missing", async () => { const store = writeStore({ - "group:demo": { + "discord:group:demo": { sessionId: "xyz", updatedAt: Date.now() - 5 * 60_000, thinkingLevel: "high", @@ -89,7 +89,7 @@ describe("sessionsCommand", () => { fs.rmSync(store); - const row = logs.find((line) => line.includes("group:demo")) ?? ""; + const row = logs.find((line) => line.includes("discord:group:demo")) ?? ""; expect(row).toContain("-".padEnd(20)); expect(row).toContain("think:high"); expect(row).toContain("5m ago"); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 538470016..7c91ecfa2 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -119,10 +119,17 @@ const formatAge = (ms: number | null | undefined) => { return `${days}d ago`; }; -function classifyKey(key: string): SessionRow["kind"] { +function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] { if (key === "global") return "global"; - if (key.startsWith("group:")) return "group"; if (key === "unknown") return "unknown"; + if (entry?.chatType === "group" || entry?.chatType === "room") return "group"; + if ( + key.startsWith("group:") || + key.includes(":group:") || + key.includes(":channel:") + ) { + return "group"; + } return "direct"; } @@ -132,7 +139,7 @@ function toRows(store: Record): SessionRow[] { const updatedAt = entry?.updatedAt ?? null; return { key, - kind: classifyKey(key), + kind: classifyKey(key, entry), updatedAt, ageMs: updatedAt ? Date.now() - updatedAt : null, sessionId: entry?.sessionId, diff --git a/src/commands/status.ts b/src/commands/status.ts index 456249495..37462379f 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -102,7 +102,7 @@ export async function getStatusSummary(): Promise { return { key, - kind: classifyKey(key), + kind: classifyKey(key, entry), sessionId: entry?.sessionId, updatedAt, age, @@ -169,10 +169,17 @@ const formatContextUsage = ( return `tokens: ${formatKTokens(used)} used, ${formatKTokens(left)} left of ${formatKTokens(contextTokens)} (${pctLabel})`; }; -const classifyKey = (key: string): SessionStatus["kind"] => { +const classifyKey = (key: string, entry?: SessionEntry): SessionStatus["kind"] => { if (key === "global") return "global"; - if (key.startsWith("group:")) return "group"; if (key === "unknown") return "unknown"; + if (entry?.chatType === "group" || entry?.chatType === "room") return "group"; + if ( + key.startsWith("group:") || + key.includes(":group:") || + key.includes(":channel:") + ) { + return "group"; + } return "direct"; }; diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index dcab9cb14..afec54a51 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -31,6 +31,26 @@ describe("sessions", () => { ); }); + it("prefixes group keys with surface when available", () => { + expect( + deriveSessionKey("per-sender", { + From: "12345-678@g.us", + ChatType: "group", + Surface: "whatsapp", + }), + ).toBe("whatsapp:group:12345-678@g.us"); + }); + + it("keeps explicit surface when provided in group key", () => { + expect( + resolveSessionKey( + "per-sender", + { From: "group:discord:12345", ChatType: "group" }, + "main", + ), + ).toBe("discord:group:12345"); + }); + it("collapses direct chats to main by default", () => { expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main"); }); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 449638101..0a4f7df60 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -10,11 +10,24 @@ import { normalizeE164 } from "../utils.js"; export type SessionScope = "per-sender" | "global"; +const GROUP_SURFACES = new Set([ + "whatsapp", + "telegram", + "discord", + "signal", + "imessage", + "webchat", + "slack", +]); + +export type SessionChatType = "direct" | "group" | "room"; + export type SessionEntry = { sessionId: string; updatedAt: number; systemSent?: boolean; abortedLastRun?: boolean; + chatType?: SessionChatType; thinkingLevel?: string; verboseLevel?: string; providerOverride?: string; @@ -27,6 +40,11 @@ export type SessionEntry = { totalTokens?: number; model?: string; contextTokens?: number; + displayName?: string; + surface?: string; + subject?: string; + room?: string; + space?: string; lastChannel?: | "whatsapp" | "telegram" @@ -38,6 +56,14 @@ export type SessionEntry = { skillsSnapshot?: SessionSkillSnapshot; }; +export type GroupKeyResolution = { + key: string; + legacyKey?: string; + surface?: string; + id?: string; + chatType?: SessionChatType; +}; + export type SessionSkillSnapshot = { prompt: string; skills: Array<{ name: string; primaryEnv?: string }>; @@ -66,6 +92,135 @@ export function resolveStorePath(store?: string) { return path.resolve(store); } +function normalizeGroupLabel(raw?: string) { + const trimmed = raw?.trim().toLowerCase() ?? ""; + if (!trimmed) return ""; + const dashed = trimmed.replace(/\s+/g, "-"); + const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-"); + return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); +} + +function shortenGroupId(value?: string) { + const trimmed = value?.trim() ?? ""; + if (!trimmed) return ""; + if (trimmed.length <= 14) return trimmed; + return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`; +} + +export function buildGroupDisplayName(params: { + surface?: string; + subject?: string; + room?: string; + space?: string; + id?: string; + key: string; +}) { + const surfaceKey = (params.surface?.trim().toLowerCase() || "group").trim(); + const detail = + params.room?.trim() || + params.subject?.trim() || + params.space?.trim() || + ""; + const fallbackId = params.id?.trim() || params.key.replace(/^group:/, ""); + const rawLabel = detail || fallbackId; + let token = normalizeGroupLabel(rawLabel); + if (!token) { + token = normalizeGroupLabel(shortenGroupId(rawLabel)); + } + if (!params.room && token.startsWith("#")) { + token = token.replace(/^#+/, ""); + } + if (token && !/^[@#]/.test(token) && !token.startsWith("g-")) { + token = `g-${token}`; + } + return token ? `${surfaceKey}:${token}` : surfaceKey; +} + +export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | null { + const from = typeof ctx.From === "string" ? ctx.From.trim() : ""; + if (!from) return null; + const chatType = ctx.ChatType?.trim().toLowerCase(); + const isGroup = + chatType === "group" || + from.startsWith("group:") || + from.includes("@g.us") || + from.includes(":group:") || + from.includes(":channel:"); + if (!isGroup) return null; + + const surfaceHint = ctx.Surface?.trim().toLowerCase(); + const hasLegacyGroupPrefix = from.startsWith("group:"); + const raw = (hasLegacyGroupPrefix ? from.slice("group:".length) : from).trim(); + + let surface: string | undefined; + let kind: "group" | "channel" | undefined; + let id = ""; + + const parseKind = (value: string) => { + if (value === "channel") return "channel"; + return "group"; + }; + + const parseParts = (parts: string[]) => { + if (parts.length >= 2 && GROUP_SURFACES.has(parts[0])) { + surface = parts[0]; + if (parts.length >= 3) { + const kindCandidate = parts[1]; + if (["group", "channel"].includes(kindCandidate)) { + kind = parseKind(kindCandidate); + id = parts.slice(2).join(":"); + } else { + id = parts.slice(1).join(":"); + } + } else { + id = parts[1]; + } + return; + } + if (parts.length >= 2 && ["group", "channel"].includes(parts[0])) { + kind = parseKind(parts[0]); + id = parts.slice(1).join(":"); + } + }; + + if (hasLegacyGroupPrefix) { + const legacyParts = raw.split(":").filter(Boolean); + if (legacyParts.length > 1) { + parseParts(legacyParts); + } else { + id = raw; + } + } else if (from.includes("@g.us") && !from.includes(":")) { + id = from; + } else { + parseParts(from.split(":").filter(Boolean)); + if (!id) { + id = raw || from; + } + } + + const resolvedSurface = surface ?? surfaceHint; + if (!resolvedSurface) { + const legacy = hasLegacyGroupPrefix ? `group:${raw}` : `group:${from}`; + return { key: legacy, id: raw || from, legacyKey: legacy, chatType: "group" }; + } + + const resolvedKind = kind === "channel" ? "channel" : "group"; + const key = `${resolvedSurface}:${resolvedKind}:${id || raw || from}`; + let legacyKey: string | undefined; + if (hasLegacyGroupPrefix || from.includes("@g.us")) { + legacyKey = `group:${id || raw || from}`; + } + + return { + key, + legacyKey, + surface: resolvedSurface, + id: id || raw || from, + chatType: resolvedKind === "channel" ? "room" : "group", + }; +} + export function loadSessionStore( storePath: string, ): Record { @@ -145,6 +300,12 @@ export async function updateLastRoute(params: { totalTokens: existing?.totalTokens, model: existing?.model, contextTokens: existing?.contextTokens, + displayName: existing?.displayName, + chatType: existing?.chatType, + surface: existing?.surface, + subject: existing?.subject, + room: existing?.room, + space: existing?.space, skillsSnapshot: existing?.skillsSnapshot, lastChannel: channel, lastTo: to?.trim() ? to.trim() : undefined, @@ -157,14 +318,9 @@ export async function updateLastRoute(params: { // Decide which session bucket to use (per-sender vs global). export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) { if (scope === "global") return "global"; + const resolvedGroup = resolveGroupSessionKey(ctx); + if (resolvedGroup) return resolvedGroup.key; const from = ctx.From ? normalizeE164(ctx.From) : ""; - // Preserve group conversations as distinct buckets - if (typeof ctx.From === "string" && ctx.From.includes("@g.us")) { - return `group:${ctx.From}`; - } - if (typeof ctx.From === "string" && ctx.From.startsWith("group:")) { - return ctx.From; - } return from || "unknown"; } @@ -181,7 +337,10 @@ export function resolveSessionKey( if (scope === "global") return raw; // Default to a single shared direct-chat session called "main"; groups stay isolated. const canonical = (mainKey ?? "main").trim() || "main"; - const isGroup = raw.startsWith("group:") || raw.includes("@g.us"); + const isGroup = + raw.startsWith("group:") || + raw.includes(":group:") || + raw.includes(":channel:"); if (!isGroup) return canonical; return raw; } diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 880e0f8cc..135a4e52c 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,4 +1,5 @@ import { + ChannelType, Client, Events, GatewayIntentBits, @@ -106,7 +107,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (message.author?.bot) return; if (!message.author) return; - const isDirectMessage = !message.guild; + const channelType = message.channel.type; + const isGroupDm = channelType === ChannelType.GroupDM; + const isDirectMessage = channelType === ChannelType.DM; + const isGuildMessage = Boolean(message.guild); const botId = client.user?.id; const wasMentioned = !isDirectMessage && Boolean(botId && message.mentions.has(botId)); @@ -142,7 +146,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } } - if (!isDirectMessage && guildAllowFrom) { + if (!isDirectMessage && isGuildMessage && guildAllowFrom) { const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [ "guild:", ]); @@ -197,7 +201,16 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const fromLabel = isDirectMessage ? buildDirectLabel(message) - : buildGuildLabel(message); + : isGroupDm + ? buildGroupDmLabel(message) + : buildGuildLabel(message); + const groupSubject = (() => { + if (isDirectMessage) return undefined; + const channelName = + "name" in message.channel ? message.channel.name : message.channelId; + if (!channelName) return undefined; + return isGuildMessage ? `#${channelName}` : channelName; + })(); const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`; let combinedBody = formatAgentEnvelope({ surface: "Discord", @@ -238,10 +251,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { : `channel:${message.channelId}`, ChatType: isDirectMessage ? "direct" : "group", SenderName: message.member?.displayName ?? message.author.tag, - GroupSubject: - !isDirectMessage && "name" in message.channel - ? message.channel.name - : undefined, + GroupSubject: groupSubject, Surface: "discord" as const, WasMentioned: wasMentioned, MessageSid: message.id, @@ -358,6 +368,13 @@ function buildDirectLabel(message: import("discord.js").Message) { return `${username} id:${message.author.id}`; } +function buildGroupDmLabel(message: import("discord.js").Message) { + const channelName = + "name" in message.channel ? message.channel.name : undefined; + const name = channelName ? ` ${channelName}` : ""; + return `Group DM${name} id:${message.channelId}`; +} + function buildGuildLabel(message: import("discord.js").Message) { const channelName = "name" in message.channel ? message.channel.name : message.channelId; diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 1e246c280..cfac4af86 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -3865,7 +3865,7 @@ describe("gateway server", () => { thinkingLevel: "low", verboseLevel: "on", }, - "group:dev": { + "discord:group:dev": { sessionId: "sess-group", updatedAt: now - 120_000, totalTokens: 50, @@ -3977,7 +3977,7 @@ describe("gateway server", () => { const deleted = await rpcReq<{ ok: true; deleted: boolean }>( ws, "sessions.delete", - { key: "group:dev" }, + { key: "discord:group:dev" }, ); expect(deleted.ok).toBe(true); expect(deleted.payload?.deleted).toBe(true); @@ -3986,7 +3986,9 @@ describe("gateway server", () => { }>(ws, "sessions.list", {}); expect(listAfterDelete.ok).toBe(true); expect( - listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev"), + listAfterDelete.payload?.sessions.some( + (s) => s.key === "discord:group:dev", + ), ).toBe(false); const filesAfterDelete = await fs.readdir(dir); expect( diff --git a/src/gateway/server.ts b/src/gateway/server.ts index a8f1589c4..e62841b34 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -55,6 +55,7 @@ import { writeConfigFile, } from "../config/config.js"; import { + buildGroupDisplayName, loadSessionStore, resolveStorePath, type SessionEntry, @@ -455,6 +456,11 @@ type GatewaySessionsDefaults = { type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; + displayName?: string; + surface?: string; + subject?: string; + room?: string; + space?: string; updatedAt: number | null; sessionId?: string; systemSent?: boolean; @@ -862,13 +868,41 @@ function loadSessionEntry(sessionKey: string) { return { cfg, storePath, store, entry }; } -function classifySessionKey(key: string): GatewaySessionRow["kind"] { +function classifySessionKey( + key: string, + entry?: SessionEntry, +): GatewaySessionRow["kind"] { if (key === "global") return "global"; - if (key.startsWith("group:")) return "group"; if (key === "unknown") return "unknown"; + if (entry?.chatType === "group" || entry?.chatType === "room") return "group"; + if ( + key.startsWith("group:") || + key.includes(":group:") || + key.includes(":channel:") + ) { + return "group"; + } return "direct"; } +function parseGroupKey( + key: string, +): { surface?: string; kind?: "group" | "channel"; id?: string } | null { + if (key.startsWith("group:")) { + const raw = key.slice("group:".length); + return raw ? { id: raw } : null; + } + const parts = key.split(":").filter(Boolean); + if (parts.length >= 3) { + const [surface, kind, ...rest] = parts; + if (kind === "group" || kind === "channel") { + const id = rest.join(":"); + return { surface, kind, id }; + } + } + return null; +} + function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults { const resolved = resolveConfiguredModelRef({ cfg, @@ -913,9 +947,32 @@ function listSessionsFromStore(params: { const input = entry?.inputTokens ?? 0; const output = entry?.outputTokens ?? 0; const total = entry?.totalTokens ?? input + output; + const parsed = parseGroupKey(key); + const surface = entry?.surface ?? parsed?.surface; + const subject = entry?.subject; + const room = entry?.room; + const space = entry?.space; + const id = parsed?.id; + const displayName = + entry?.displayName ?? + (surface + ? buildGroupDisplayName({ + surface, + subject, + room, + space, + id, + key, + }) + : undefined); return { key, - kind: classifySessionKey(key), + kind: classifySessionKey(key, entry), + displayName, + surface, + subject, + room, + space, updatedAt, sessionId: entry?.sessionId, systemSent: entry?.systemSent, @@ -2881,6 +2938,12 @@ export async function startGatewayServer( verboseLevel: entry?.verboseLevel, model: entry?.model, contextTokens: entry?.contextTokens, + displayName: entry?.displayName, + chatType: entry?.chatType, + surface: entry?.surface, + subject: entry?.subject, + room: entry?.room, + space: entry?.space, lastChannel: entry?.lastChannel, lastTo: entry?.lastTo, skillsSnapshot: entry?.skillsSnapshot, diff --git a/src/imessage/targets.ts b/src/imessage/targets.ts index eaa5c8428..c1f1def41 100644 --- a/src/imessage/targets.ts +++ b/src/imessage/targets.ts @@ -52,6 +52,23 @@ export function parseIMessageTarget(raw: string): IMessageTarget { if (!trimmed) throw new Error("iMessage target is required"); const lower = trimmed.toLowerCase(); + for (const { prefix, service } of SERVICE_PREFIXES) { + if (lower.startsWith(prefix)) { + const remainder = stripPrefix(trimmed, prefix); + if (!remainder) throw new Error(`${prefix} target is required`); + const remainderLower = remainder.toLowerCase(); + const isChatTarget = + CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || + CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) || + CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) || + remainderLower.startsWith("group:"); + if (isChatTarget) { + return parseIMessageTarget(remainder); + } + return { kind: "handle", to: remainder, service }; + } + } + for (const prefix of CHAT_ID_PREFIXES) { if (lower.startsWith(prefix)) { const value = stripPrefix(trimmed, prefix); @@ -89,14 +106,6 @@ export function parseIMessageTarget(raw: string): IMessageTarget { return { kind: "chat_guid", chatGuid: value }; } - for (const { prefix, service } of SERVICE_PREFIXES) { - if (lower.startsWith(prefix)) { - const to = stripPrefix(trimmed, prefix); - if (!to) throw new Error(`${prefix} target is required`); - return { kind: "handle", to, service }; - } - } - return { kind: "handle", to: trimmed, service: "auto" }; } @@ -105,6 +114,14 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget { if (!trimmed) return { kind: "handle", handle: "" }; const lower = trimmed.toLowerCase(); + for (const { prefix } of SERVICE_PREFIXES) { + if (lower.startsWith(prefix)) { + const remainder = stripPrefix(trimmed, prefix); + if (!remainder) return { kind: "handle", handle: "" }; + return parseIMessageAllowTarget(remainder); + } + } + for (const prefix of CHAT_ID_PREFIXES) { if (lower.startsWith(prefix)) { const value = stripPrefix(trimmed, prefix); diff --git a/src/signal/send.ts b/src/signal/send.ts index c82ae8ce0..b05765e0e 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -43,19 +43,20 @@ function parseTarget(raw: string): SignalTarget { let value = raw.trim(); if (!value) throw new Error("Signal recipient is required"); const lower = value.toLowerCase(); - if (lower.startsWith("group:")) { - return { type: "group", groupId: value.slice("group:".length).trim() }; - } if (lower.startsWith("signal:")) { value = value.slice("signal:".length).trim(); } - if (lower.startsWith("username:")) { + const normalized = value.toLowerCase(); + if (normalized.startsWith("group:")) { + return { type: "group", groupId: value.slice("group:".length).trim() }; + } + if (normalized.startsWith("username:")) { return { type: "username", username: value.slice("username:".length).trim(), }; } - if (lower.startsWith("u:")) { + if (normalized.startsWith("u:")) { return { type: "username", username: value.trim() }; } return { type: "recipient", recipient: value }; diff --git a/src/telegram/send.ts b/src/telegram/send.ts index ad9ac64e9..06b50da5b 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -36,7 +36,7 @@ function normalizeChatId(to: string): string { // Common internal prefixes that sometimes leak into outbound sends. // - ctx.To uses `telegram:` - // - group sessions often use `group:` + // - group sessions often use `telegram:group:` let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); // Accept t.me links for public chats/channels. diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 11657ab91..05b217e7b 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1015,7 +1015,7 @@ describe("web auto-reply", () => { .mockResolvedValueOnce({ text: "ok" }); const { storePath, cleanup } = await makeSessionStore({ - "group:123@g.us": { + "whatsapp:group:123@g.us": { sessionId: "g-1", updatedAt: Date.now(), groupActivation: "always", diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 74ff33072..762cb8135 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -412,7 +412,10 @@ function getSessionRecipients(cfg: ReturnType) { const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); const isGroupKey = (key: string) => - key.startsWith("group:") || key.includes("@g.us"); + key.startsWith("group:") || + key.includes(":group:") || + key.includes(":channel:") || + key.includes("@g.us"); const isCronKey = (key: string) => key.startsWith("cron:"); const recipients = Object.entries(store) @@ -812,7 +815,7 @@ export async function monitorWebProvider( const resolveGroupActivationFor = (conversationId: string) => { const key = conversationId.startsWith("group:") ? conversationId - : `group:${conversationId}`; + : `whatsapp:group:${conversationId}`; const store = loadSessionStore(sessionStorePath); const entry = store[key]; const requireMention = cfg.routing?.groupChat?.requireMention; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index f804a2564..1d2c36b5d 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -100,6 +100,11 @@ export type GatewaySessionsDefaults = { export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; + displayName?: string; + surface?: string; + subject?: string; + room?: string; + space?: string; updatedAt: number | null; sessionId?: string; systemSent?: boolean; diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 6eed6612e..c82526538 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -130,7 +130,7 @@ function renderRow(row: GatewaySessionRow, onPatch: SessionsProps["onPatch"]) { const verbose = row.verboseLevel ?? ""; return html`
-
${row.key}
+
${row.displayName ?? row.key}
${row.kind}
${updated}
${formatSessionTokens(row)}