refactor: normalize group session keys

This commit is contained in:
Peter Steinberger
2026-01-02 10:14:58 +01:00
parent 35582cfe8a
commit 9adbf47773
48 changed files with 537 additions and 86 deletions

View File

@@ -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:<id>` / `surface:channel:<id>`; 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.

View File

@@ -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)
}
}

View File

@@ -25,6 +25,7 @@ data class ChatPendingToolCall(
data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val displayName: String? = null,
)
data class ChatHistory(

View File

@@ -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

View File

@@ -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(
}
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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",

View File

@@ -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"))
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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?

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:<jid>`; heartbeats keep background tasks alive.
- Direct chats collapse into the shared `main` session by default; groups stay isolated as `surface:group:<id>` (rooms: `surface:channel:<id>`); heartbeats keep background tasks alive.
## Core Skills (enable in Settings → Skills)
- **mcporter** — Tool server runtime/CLI for managing external skill backends.

View File

@@ -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:<channelId>`.
- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `discord:group:<channelId>`.
- 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

View File

@@ -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 grammYs `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:<chatId>`; replies route back to the same surface.
- **Sessions:** direct chats map to `main`; groups map to `telegram:group:<chatId>`; 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.

View File

@@ -10,7 +10,7 @@ Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that
## Whats implemented (2025-12-03)
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bots 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:<jid>` 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:<jid>` 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 bots 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:<jid>` in the session store (`~/.clawdis/sessions/sessions.json` by default); a missing entry just means the group hasnt triggered a run yet.
- Session store entries will appear as `whatsapp:group:<jid>` in the session store (`~/.clawdis/sessions/sessions.json` by default); a missing entry just means the group hasnt triggered a run yet.

View File

@@ -8,10 +8,14 @@ read_when:
Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage.
## Session keys
- Group sessions use `group:<id>` in `ctx.From`.
- Group sessions use `surface:group:<id>` session keys (rooms/channels use `surface:channel:<id>`).
- 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:<token>`.
- `#room` is reserved for rooms/channels; group chats use `g-<slug>` (lowercase, spaces -> `-`, keep `#@+._-`).
## Mention gating (default)
Group messages require a mention unless overridden per group.

View File

@@ -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/<SessionId>.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:<jid>` keys; do not reuse the primary key for groups.
- Group chats isolate state with `surface:group:<id>` keys (rooms/channels use `surface:channel:<id>`); do not reuse the primary key for groups.
- Legacy `group:<surface>:<id>` and `group:<id>` keys are still recognized.
## Lifecyle
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.

8
docs/sessions.md Normal file
View File

@@ -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`.

View File

@@ -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:<groupId>`
- Groups: `signal:group:<groupId>`
- Usernames: `username:<name>` / `u:<name>`
## Process plan (Clawdis adapter)

View File

@@ -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 doesnt 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:<jid>`, 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:<id>` (rooms: `surface:channel:<id>`), so they remain isolated.
- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdis/sessions/<SessionId>.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:**

View File

@@ -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:<chatId>`.
- Share the same `main` session used by WhatsApp/WebChat; groups stay isolated as `telegram:group:<chatId>`.
- 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:<chatId>` 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:<chatId>` 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)

View File

@@ -52,7 +52,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
- `<media:image|video|audio|document|sticker>`
## Groups
- Groups map to `group:<jid>` sessions.
- Groups map to `whatsapp:group:<jid>` sessions.
- Activation modes:
- `mention` (default): requires @mention or regex match.
- `always`: always triggers.

View File

@@ -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<string, { groupActivation?: string }>;
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)",

View File

@@ -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) {

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -470,7 +470,7 @@ export async function agentCommand(
}
if (deliveryProvider === "signal" && !signalTarget) {
const err = new Error(
"Delivering to Signal requires --to <E.164|group:ID|signal:+E.164>",
"Delivering to Signal requires --to <E.164|group:ID|signal:group:ID|signal:+E.164>",
);
if (!bestEffortDeliver) throw err;
logDeliveryError(err);

View File

@@ -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");

View File

@@ -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<string, SessionEntry>): 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,

View File

@@ -102,7 +102,7 @@ export async function getStatusSummary(): Promise<StatusSummary> {
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";
};

View File

@@ -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");
});

View File

@@ -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<string, SessionEntry> {
@@ -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;
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 };

View File

@@ -36,7 +36,7 @@ function normalizeChatId(to: string): string {
// Common internal prefixes that sometimes leak into outbound sends.
// - ctx.To uses `telegram:<id>`
// - group sessions often use `group:<id>`
// - group sessions often use `telegram:group:<id>`
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
// Accept t.me links for public chats/channels.

View File

@@ -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",

View File

@@ -412,7 +412,10 @@ function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
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;

View File

@@ -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;

View File

@@ -130,7 +130,7 @@ function renderRow(row: GatewaySessionRow, onPatch: SessionsProps["onPatch"]) {
const verbose = row.verboseLevel ?? "";
return html`
<div class="table-row">
<div class="mono">${row.key}</div>
<div class="mono">${row.displayName ?? row.key}</div>
<div>${row.kind}</div>
<div>${updated}</div>
<div>${formatSessionTokens(row)}</div>