refactor: normalize group session keys
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
- `skillsInstall.*` → `skills.install.*`
|
- `skillsInstall.*` → `skills.install.*`
|
||||||
- per-skill config map moved to `skills.entries` (e.g. `skills.peekaboo.enabled` → `skills.entries.peekaboo.enabled`)
|
- 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)
|
- 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
|
### Features
|
||||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||||
|
|||||||
@@ -469,7 +469,8 @@ class ChatController(
|
|||||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||||
if (key.isEmpty()) return@mapNotNull null
|
if (key.isEmpty()) return@mapNotNull null
|
||||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt)
|
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||||
|
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ data class ChatPendingToolCall(
|
|||||||
data class ChatSessionEntry(
|
data class ChatSessionEntry(
|
||||||
val key: String,
|
val key: String,
|
||||||
val updatedAtMs: Long?,
|
val updatedAtMs: Long?,
|
||||||
|
val displayName: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ChatHistory(
|
data class ChatHistory(
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ fun ChatComposer(
|
|||||||
var showSessionMenu by remember { mutableStateOf(false) }
|
var showSessionMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||||
|
val currentSessionLabel =
|
||||||
|
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||||
|
|
||||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||||
|
|
||||||
@@ -82,13 +84,13 @@ fun ChatComposer(
|
|||||||
onClick = { showSessionMenu = true },
|
onClick = { showSessionMenu = true },
|
||||||
contentPadding = ButtonDefaults.ContentPadding,
|
contentPadding = ButtonDefaults.ContentPadding,
|
||||||
) {
|
) {
|
||||||
Text("Session: $sessionKey")
|
Text("Session: $currentSessionLabel")
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
||||||
for (entry in sessionOptions) {
|
for (entry in sessionOptions) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(entry.key) },
|
text = { Text(entry.displayName ?: entry.key) },
|
||||||
onClick = {
|
onClick = {
|
||||||
onSelectSession(entry.key)
|
onSelectSession(entry.key)
|
||||||
showSessionMenu = false
|
showSessionMenu = false
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ private fun SessionRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
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))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -90,4 +90,3 @@ private fun SessionRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ struct ContextMenuCardView: View {
|
|||||||
height: self.barHeight)
|
height: self.barHeight)
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Text(row.key)
|
Text(row.label)
|
||||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ struct GatewaySessionDefaultsRecord: Codable {
|
|||||||
|
|
||||||
struct GatewaySessionEntryRecord: Codable {
|
struct GatewaySessionEntryRecord: Codable {
|
||||||
let key: String
|
let key: String
|
||||||
|
let displayName: String?
|
||||||
|
let surface: String?
|
||||||
|
let subject: String?
|
||||||
|
let room: String?
|
||||||
|
let space: String?
|
||||||
let updatedAt: Double?
|
let updatedAt: Double?
|
||||||
let sessionId: String?
|
let sessionId: String?
|
||||||
let systemSent: Bool?
|
let systemSent: Bool?
|
||||||
@@ -65,6 +70,11 @@ struct SessionRow: Identifiable {
|
|||||||
let id: String
|
let id: String
|
||||||
let key: String
|
let key: String
|
||||||
let kind: SessionKind
|
let kind: SessionKind
|
||||||
|
let displayName: String?
|
||||||
|
let surface: String?
|
||||||
|
let subject: String?
|
||||||
|
let room: String?
|
||||||
|
let space: String?
|
||||||
let updatedAt: Date?
|
let updatedAt: Date?
|
||||||
let sessionId: String?
|
let sessionId: String?
|
||||||
let thinkingLevel: String?
|
let thinkingLevel: String?
|
||||||
@@ -75,6 +85,7 @@ struct SessionRow: Identifiable {
|
|||||||
let model: String?
|
let model: String?
|
||||||
|
|
||||||
var ageText: String { relativeAge(from: self.updatedAt) }
|
var ageText: String { relativeAge(from: self.updatedAt) }
|
||||||
|
var label: String { self.displayName ?? self.key }
|
||||||
|
|
||||||
var flagLabels: [String] {
|
var flagLabels: [String] {
|
||||||
var flags: [String] = []
|
var flags: [String] = []
|
||||||
@@ -92,6 +103,8 @@ enum SessionKind {
|
|||||||
static func from(key: String) -> SessionKind {
|
static func from(key: String) -> SessionKind {
|
||||||
if key == "global" { return .global }
|
if key == "global" { return .global }
|
||||||
if key.hasPrefix("group:") { return .group }
|
if key.hasPrefix("group:") { return .group }
|
||||||
|
if key.contains(":group:") { return .group }
|
||||||
|
if key.contains(":channel:") { return .group }
|
||||||
if key == "unknown" { return .unknown }
|
if key == "unknown" { return .unknown }
|
||||||
return .direct
|
return .direct
|
||||||
}
|
}
|
||||||
@@ -127,6 +140,11 @@ extension SessionRow {
|
|||||||
id: "direct-1",
|
id: "direct-1",
|
||||||
key: "user@example.com",
|
key: "user@example.com",
|
||||||
kind: .direct,
|
kind: .direct,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date().addingTimeInterval(-90),
|
updatedAt: Date().addingTimeInterval(-90),
|
||||||
sessionId: "sess-direct-1234",
|
sessionId: "sess-direct-1234",
|
||||||
thinkingLevel: "low",
|
thinkingLevel: "low",
|
||||||
@@ -137,8 +155,13 @@ extension SessionRow {
|
|||||||
model: "claude-3.5-sonnet"),
|
model: "claude-3.5-sonnet"),
|
||||||
SessionRow(
|
SessionRow(
|
||||||
id: "group-1",
|
id: "group-1",
|
||||||
key: "group:engineering",
|
key: "discord:channel:release-squad",
|
||||||
kind: .group,
|
kind: .group,
|
||||||
|
displayName: "discord:#release-squad",
|
||||||
|
surface: "discord",
|
||||||
|
subject: nil,
|
||||||
|
room: "#release-squad",
|
||||||
|
space: nil,
|
||||||
updatedAt: Date().addingTimeInterval(-3600),
|
updatedAt: Date().addingTimeInterval(-3600),
|
||||||
sessionId: "sess-group-4321",
|
sessionId: "sess-group-4321",
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
@@ -151,6 +174,11 @@ extension SessionRow {
|
|||||||
id: "global",
|
id: "global",
|
||||||
key: "global",
|
key: "global",
|
||||||
kind: .global,
|
kind: .global,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date().addingTimeInterval(-86400),
|
updatedAt: Date().addingTimeInterval(-86400),
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
thinkingLevel: nil,
|
thinkingLevel: nil,
|
||||||
@@ -269,6 +297,11 @@ enum SessionLoader {
|
|||||||
id: entry.key,
|
id: entry.key,
|
||||||
key: entry.key,
|
key: entry.key,
|
||||||
kind: SessionKind.from(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,
|
updatedAt: updated,
|
||||||
sessionId: entry.sessionId,
|
sessionId: entry.sessionId,
|
||||||
thinkingLevel: entry.thinkingLevel,
|
thinkingLevel: entry.thinkingLevel,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ struct SessionMenuLabelView: View {
|
|||||||
height: self.barHeight)
|
height: self.barHeight)
|
||||||
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Text(self.row.key)
|
Text(self.row.label)
|
||||||
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
|
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
|
||||||
.foregroundStyle(self.primaryTextColor)
|
.foregroundStyle(self.primaryTextColor)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ struct SessionsSettings: View {
|
|||||||
private func sessionRow(_ row: SessionRow) -> some View {
|
private func sessionRow(_ row: SessionRow) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Text(row.key)
|
Text(row.label)
|
||||||
.font(.subheadline.bold())
|
.font(.subheadline.bold())
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ struct MenuSessionsInjectorTests {
|
|||||||
id: "main",
|
id: "main",
|
||||||
key: "main",
|
key: "main",
|
||||||
kind: .direct,
|
kind: .direct,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date(),
|
updatedAt: Date(),
|
||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
thinkingLevel: "low",
|
thinkingLevel: "low",
|
||||||
@@ -38,9 +43,14 @@ struct MenuSessionsInjectorTests {
|
|||||||
tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000),
|
tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000),
|
||||||
model: "claude-opus-4-5"),
|
model: "claude-opus-4-5"),
|
||||||
SessionRow(
|
SessionRow(
|
||||||
id: "group:alpha",
|
id: "discord:group:alpha",
|
||||||
key: "group:alpha",
|
key: "discord:group:alpha",
|
||||||
kind: .group,
|
kind: .group,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date(timeIntervalSinceNow: -60),
|
updatedAt: Date(timeIntervalSinceNow: -60),
|
||||||
sessionId: "s2",
|
sessionId: "s2",
|
||||||
thinkingLevel: "high",
|
thinkingLevel: "high",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Testing
|
|||||||
struct SessionDataTests {
|
struct SessionDataTests {
|
||||||
@Test func sessionKindFromKeyDetectsCommonKinds() {
|
@Test func sessionKindFromKeyDetectsCommonKinds() {
|
||||||
#expect(SessionKind.from(key: "global") == .global)
|
#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: "unknown") == .unknown)
|
||||||
#expect(SessionKind.from(key: "user@example.com") == .direct)
|
#expect(SessionKind.from(key: "user@example.com") == .direct)
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,11 @@ struct SessionDataTests {
|
|||||||
id: "x",
|
id: "x",
|
||||||
key: "user@example.com",
|
key: "user@example.com",
|
||||||
kind: .direct,
|
kind: .direct,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: Date(),
|
updatedAt: Date(),
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
thinkingLevel: "high",
|
thinkingLevel: "high",
|
||||||
@@ -41,4 +46,3 @@ struct SessionDataTests {
|
|||||||
#expect(row.flagLabels.contains("aborted"))
|
#expect(row.flagLabels.contains("aborted"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ struct WorkActivityStoreTests {
|
|||||||
@Test func mainSessionJobPreemptsOther() {
|
@Test func mainSessionJobPreemptsOther() {
|
||||||
let store = WorkActivityStore()
|
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.iconState == .workingOther(.job))
|
||||||
#expect(store.current?.sessionKey == "group:1")
|
#expect(store.current?.sessionKey == "discord:group:1")
|
||||||
|
|
||||||
store.handleJob(sessionKey: "main", state: "started")
|
store.handleJob(sessionKey: "main", state: "started")
|
||||||
#expect(store.iconState == .workingMain(.job))
|
#expect(store.iconState == .workingMain(.job))
|
||||||
@@ -18,9 +18,9 @@ struct WorkActivityStoreTests {
|
|||||||
|
|
||||||
store.handleJob(sessionKey: "main", state: "finished")
|
store.handleJob(sessionKey: "main", state: "finished")
|
||||||
#expect(store.iconState == .workingOther(.job))
|
#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.iconState == .idle)
|
||||||
#expect(store.current == nil)
|
#expect(store.current == nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ struct ClawdisChatComposer: View {
|
|||||||
set: { next in self.viewModel.switchSession(to: next) }))
|
set: { next in self.viewModel.switchSession(to: next) }))
|
||||||
{
|
{
|
||||||
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
|
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
|
||||||
Text(session.key)
|
Text(session.displayName ?? session.key)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.tag(session.key)
|
.tag(session.key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable
|
|||||||
|
|
||||||
public let key: String
|
public let key: String
|
||||||
public let kind: 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 updatedAt: Double?
|
||||||
public let sessionId: String?
|
public let sessionId: String?
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ struct ChatSessionsSheet: View {
|
|||||||
self.dismiss()
|
self.dismiss()
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(session.key)
|
Text(session.displayName ?? session.key)
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
||||||
|
|||||||
@@ -341,6 +341,11 @@ public final class ClawdisChatViewModel {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: key,
|
key: key,
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: nil,
|
updatedAt: nil,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
|
|||||||
@@ -282,6 +282,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "recent-1",
|
key: "recent-1",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: recent,
|
updatedAt: recent,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
@@ -296,6 +301,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "main",
|
key: "main",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: stale,
|
updatedAt: stale,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
@@ -310,6 +320,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "recent-2",
|
key: "recent-2",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: recentOlder,
|
updatedAt: recentOlder,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
@@ -324,6 +339,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "old-1",
|
key: "old-1",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: stale,
|
updatedAt: stale,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
@@ -365,6 +385,11 @@ private extension TestChatTransportState {
|
|||||||
ClawdisChatSessionEntry(
|
ClawdisChatSessionEntry(
|
||||||
key: "main",
|
key: "main",
|
||||||
kind: nil,
|
kind: nil,
|
||||||
|
displayName: nil,
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
updatedAt: recent,
|
updatedAt: recent,
|
||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
systemSent: nil,
|
systemSent: nil,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ git commit -m "Add Clawd workspace"
|
|||||||
## What Clawdis Does
|
## 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.
|
- 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.
|
- 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)
|
## Core Skills (enable in Settings → Skills)
|
||||||
- **mcporter** — Tool server runtime/CLI for managing external skill backends.
|
- **mcporter** — Tool server runtime/CLI for managing external skill backends.
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Talk to Clawdis via Discord DMs or guild channels.
|
- 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.
|
- Keep routing deterministic: replies always go back to the surface they arrived on.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|||||||
@@ -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`.
|
- **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`.
|
- **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).
|
- **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`.
|
- **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.
|
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
## 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.
|
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Activation is controlled per group (command or UI), not via config.
|
||||||
- Group allowlist bypass: we still enforce `routing.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
- Group allowlist bypass: we still enforce `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]`.
|
- 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.
|
- 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.
|
- 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
|
## Known considerations
|
||||||
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
|
- 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.
|
- 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 hasn’t 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 hasn’t triggered a run yet.
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ read_when:
|
|||||||
Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage.
|
Clawdis treats group chats consistently across surfaces: WhatsApp, Telegram, Discord, iMessage.
|
||||||
|
|
||||||
## Session keys
|
## 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).
|
- Direct chats use the main session (or per-sender if configured).
|
||||||
- Heartbeats are skipped for group sessions.
|
- 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)
|
## Mention gating (default)
|
||||||
Group messages require a mention unless overridden per group.
|
Group messages require a mention unless overridden per group.
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
- Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`).
|
||||||
- Transcripts: `~/.clawdis/sessions/<SessionId>.jsonl` (one file per session id).
|
- 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.
|
- 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.
|
- Clawdis does **not** read legacy Pi/Tau session folders.
|
||||||
|
|
||||||
## Mapping transports → session keys
|
## Mapping transports → session keys
|
||||||
- Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context.
|
- 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.
|
- 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
|
## Lifecyle
|
||||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
||||||
|
|||||||
8
docs/sessions.md
Normal file
8
docs/sessions.md
Normal 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`.
|
||||||
@@ -73,7 +73,7 @@ You can still run Clawdis on your own Signal account if your goal is “respond
|
|||||||
|
|
||||||
## Addressing (send targets)
|
## Addressing (send targets)
|
||||||
- Direct: `signal:+15551234567` (or plain `+15551234567`)
|
- Direct: `signal:+15551234567` (or plain `+15551234567`)
|
||||||
- Groups: `group:<groupId>`
|
- Groups: `signal:group:<groupId>`
|
||||||
- Usernames: `username:<name>` / `u:<name>`
|
- Usernames: `username:<name>` / `u:<name>`
|
||||||
|
|
||||||
## Process plan (Clawdis adapter)
|
## Process plan (Clawdis adapter)
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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`.
|
- **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.
|
- **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:**
|
- **Implementation hints:**
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Let you talk to Clawdis via a Telegram bot in DMs and groups.
|
- 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.
|
- Keep transport routing deterministic: replies always go back to the surface they arrived on.
|
||||||
|
|
||||||
## How it will work (Bot API)
|
## 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.
|
- 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`.
|
- 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).
|
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`).
|
6) Optional allowlist: reuse `routing.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`).
|
||||||
|
|
||||||
## Capabilities & limits (Bot API)
|
## Capabilities & limits (Bot API)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session.
|
|||||||
- `<media:image|video|audio|document|sticker>`
|
- `<media:image|video|audio|document|sticker>`
|
||||||
|
|
||||||
## Groups
|
## Groups
|
||||||
- Groups map to `group:<jid>` sessions.
|
- Groups map to `whatsapp:group:<jid>` sessions.
|
||||||
- Activation modes:
|
- Activation modes:
|
||||||
- `mention` (default): requires @mention or regex match.
|
- `mention` (default): requires @mention or regex match.
|
||||||
- `always`: always triggers.
|
- `always`: always triggers.
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ describe("trigger handling", () => {
|
|||||||
From: "123@g.us",
|
From: "123@g.us",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
|
Surface: "whatsapp",
|
||||||
SenderE164: "+2000",
|
SenderE164: "+2000",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@@ -230,7 +231,7 @@ describe("trigger handling", () => {
|
|||||||
const store = JSON.parse(
|
const store = JSON.parse(
|
||||||
await fs.readFile(cfg.session.store, "utf-8"),
|
await fs.readFile(cfg.session.store, "utf-8"),
|
||||||
) as Record<string, { groupActivation?: string }>;
|
) 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();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -244,6 +245,7 @@ describe("trigger handling", () => {
|
|||||||
From: "123@g.us",
|
From: "123@g.us",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
|
Surface: "whatsapp",
|
||||||
SenderE164: "+999",
|
SenderE164: "+999",
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
@@ -270,6 +272,7 @@ describe("trigger handling", () => {
|
|||||||
From: "123@g.us",
|
From: "123@g.us",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group",
|
||||||
|
Surface: "whatsapp",
|
||||||
SenderE164: "+2000",
|
SenderE164: "+2000",
|
||||||
GroupSubject: "Test Group",
|
GroupSubject: "Test Group",
|
||||||
GroupMembers: "Alice (+1), Bob (+2)",
|
GroupMembers: "Alice (+1), Bob (+2)",
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
DEFAULT_RESET_TRIGGERS,
|
DEFAULT_RESET_TRIGGERS,
|
||||||
|
buildGroupDisplayName,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
resolveGroupSessionKey,
|
||||||
resolveSessionKey,
|
resolveSessionKey,
|
||||||
resolveSessionTranscriptPath,
|
resolveSessionTranscriptPath,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
@@ -364,9 +366,9 @@ export async function getReplyFromConfig(
|
|||||||
let persistedModelOverride: string | undefined;
|
let persistedModelOverride: string | undefined;
|
||||||
let persistedProviderOverride: string | undefined;
|
let persistedProviderOverride: string | undefined;
|
||||||
|
|
||||||
|
const groupResolution = resolveGroupSessionKey(ctx);
|
||||||
const isGroup =
|
const isGroup =
|
||||||
typeof ctx.From === "string" &&
|
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
|
||||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
|
||||||
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
|
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
@@ -399,6 +401,16 @@ export async function getReplyFromConfig(
|
|||||||
|
|
||||||
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
|
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
|
||||||
sessionStore = loadSessionStore(storePath);
|
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 entry = sessionStore[sessionKey];
|
||||||
const idleMs = idleMinutes * 60_000;
|
const idleMs = idleMinutes * 60_000;
|
||||||
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
|
||||||
@@ -431,7 +443,35 @@ export async function getReplyFromConfig(
|
|||||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||||
queueMode: baseEntry?.queueMode,
|
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;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
await saveSessionStore(storePath, sessionStore);
|
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.
|
// 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.
|
// Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact.
|
||||||
const isGroupSession =
|
const isGroupSession =
|
||||||
typeof ctx.From === "string" &&
|
sessionEntry?.chatType === "group" || sessionEntry?.chatType === "room";
|
||||||
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
|
|
||||||
const isMainSession =
|
const isMainSession =
|
||||||
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
||||||
if (isMainSession) {
|
if (isMainSession) {
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ describe("buildStatusMessage", () => {
|
|||||||
sessionId: "g1",
|
sessionId: "g1",
|
||||||
updatedAt: 0,
|
updatedAt: 0,
|
||||||
groupActivation: "always",
|
groupActivation: "always",
|
||||||
|
chatType: "group",
|
||||||
},
|
},
|
||||||
sessionKey: "group:123@g.us",
|
sessionKey: "whatsapp:group:123@g.us",
|
||||||
sessionScope: "per-sender",
|
sessionScope: "per-sender",
|
||||||
webLinked: true,
|
webLinked: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -191,7 +191,13 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" • ");
|
.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"}`
|
? `Group activation: ${entry?.groupActivation ?? "mention"}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ export async function agentCommand(
|
|||||||
}
|
}
|
||||||
if (deliveryProvider === "signal" && !signalTarget) {
|
if (deliveryProvider === "signal" && !signalTarget) {
|
||||||
const err = new Error(
|
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;
|
if (!bestEffortDeliver) throw err;
|
||||||
logDeliveryError(err);
|
logDeliveryError(err);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe("sessionsCommand", () => {
|
|||||||
|
|
||||||
it("shows placeholder rows when tokens are missing", async () => {
|
it("shows placeholder rows when tokens are missing", async () => {
|
||||||
const store = writeStore({
|
const store = writeStore({
|
||||||
"group:demo": {
|
"discord:group:demo": {
|
||||||
sessionId: "xyz",
|
sessionId: "xyz",
|
||||||
updatedAt: Date.now() - 5 * 60_000,
|
updatedAt: Date.now() - 5 * 60_000,
|
||||||
thinkingLevel: "high",
|
thinkingLevel: "high",
|
||||||
@@ -89,7 +89,7 @@ describe("sessionsCommand", () => {
|
|||||||
|
|
||||||
fs.rmSync(store);
|
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("-".padEnd(20));
|
||||||
expect(row).toContain("think:high");
|
expect(row).toContain("think:high");
|
||||||
expect(row).toContain("5m ago");
|
expect(row).toContain("5m ago");
|
||||||
|
|||||||
@@ -119,10 +119,17 @@ const formatAge = (ms: number | null | undefined) => {
|
|||||||
return `${days}d ago`;
|
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 === "global") return "global";
|
||||||
if (key.startsWith("group:")) return "group";
|
|
||||||
if (key === "unknown") return "unknown";
|
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";
|
return "direct";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +139,7 @@ function toRows(store: Record<string, SessionEntry>): SessionRow[] {
|
|||||||
const updatedAt = entry?.updatedAt ?? null;
|
const updatedAt = entry?.updatedAt ?? null;
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
kind: classifyKey(key),
|
kind: classifyKey(key, entry),
|
||||||
updatedAt,
|
updatedAt,
|
||||||
ageMs: updatedAt ? Date.now() - updatedAt : null,
|
ageMs: updatedAt ? Date.now() - updatedAt : null,
|
||||||
sessionId: entry?.sessionId,
|
sessionId: entry?.sessionId,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function getStatusSummary(): Promise<StatusSummary> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
kind: classifyKey(key),
|
kind: classifyKey(key, entry),
|
||||||
sessionId: entry?.sessionId,
|
sessionId: entry?.sessionId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
age,
|
age,
|
||||||
@@ -169,10 +169,17 @@ const formatContextUsage = (
|
|||||||
return `tokens: ${formatKTokens(used)} used, ${formatKTokens(left)} left of ${formatKTokens(contextTokens)} (${pctLabel})`;
|
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 === "global") return "global";
|
||||||
if (key.startsWith("group:")) return "group";
|
|
||||||
if (key === "unknown") return "unknown";
|
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";
|
return "direct";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
it("collapses direct chats to main by default", () => {
|
||||||
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main");
|
expect(resolveSessionKey("per-sender", { From: "+1555" })).toBe("main");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,11 +10,24 @@ import { normalizeE164 } from "../utils.js";
|
|||||||
|
|
||||||
export type SessionScope = "per-sender" | "global";
|
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 = {
|
export type SessionEntry = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
abortedLastRun?: boolean;
|
abortedLastRun?: boolean;
|
||||||
|
chatType?: SessionChatType;
|
||||||
thinkingLevel?: string;
|
thinkingLevel?: string;
|
||||||
verboseLevel?: string;
|
verboseLevel?: string;
|
||||||
providerOverride?: string;
|
providerOverride?: string;
|
||||||
@@ -27,6 +40,11 @@ export type SessionEntry = {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
|
displayName?: string;
|
||||||
|
surface?: string;
|
||||||
|
subject?: string;
|
||||||
|
room?: string;
|
||||||
|
space?: string;
|
||||||
lastChannel?:
|
lastChannel?:
|
||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
@@ -38,6 +56,14 @@ export type SessionEntry = {
|
|||||||
skillsSnapshot?: SessionSkillSnapshot;
|
skillsSnapshot?: SessionSkillSnapshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GroupKeyResolution = {
|
||||||
|
key: string;
|
||||||
|
legacyKey?: string;
|
||||||
|
surface?: string;
|
||||||
|
id?: string;
|
||||||
|
chatType?: SessionChatType;
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionSkillSnapshot = {
|
export type SessionSkillSnapshot = {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||||
@@ -66,6 +92,135 @@ export function resolveStorePath(store?: string) {
|
|||||||
return path.resolve(store);
|
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(
|
export function loadSessionStore(
|
||||||
storePath: string,
|
storePath: string,
|
||||||
): Record<string, SessionEntry> {
|
): Record<string, SessionEntry> {
|
||||||
@@ -145,6 +300,12 @@ export async function updateLastRoute(params: {
|
|||||||
totalTokens: existing?.totalTokens,
|
totalTokens: existing?.totalTokens,
|
||||||
model: existing?.model,
|
model: existing?.model,
|
||||||
contextTokens: existing?.contextTokens,
|
contextTokens: existing?.contextTokens,
|
||||||
|
displayName: existing?.displayName,
|
||||||
|
chatType: existing?.chatType,
|
||||||
|
surface: existing?.surface,
|
||||||
|
subject: existing?.subject,
|
||||||
|
room: existing?.room,
|
||||||
|
space: existing?.space,
|
||||||
skillsSnapshot: existing?.skillsSnapshot,
|
skillsSnapshot: existing?.skillsSnapshot,
|
||||||
lastChannel: channel,
|
lastChannel: channel,
|
||||||
lastTo: to?.trim() ? to.trim() : undefined,
|
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).
|
// Decide which session bucket to use (per-sender vs global).
|
||||||
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
|
||||||
if (scope === "global") return "global";
|
if (scope === "global") return "global";
|
||||||
|
const resolvedGroup = resolveGroupSessionKey(ctx);
|
||||||
|
if (resolvedGroup) return resolvedGroup.key;
|
||||||
const from = ctx.From ? normalizeE164(ctx.From) : "";
|
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";
|
return from || "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +337,10 @@ export function resolveSessionKey(
|
|||||||
if (scope === "global") return raw;
|
if (scope === "global") return raw;
|
||||||
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
||||||
const canonical = (mainKey ?? "main").trim() || "main";
|
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;
|
if (!isGroup) return canonical;
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ChannelType,
|
||||||
Client,
|
Client,
|
||||||
Events,
|
Events,
|
||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
@@ -106,7 +107,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
if (message.author?.bot) return;
|
if (message.author?.bot) return;
|
||||||
if (!message.author) 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 botId = client.user?.id;
|
||||||
const wasMentioned =
|
const wasMentioned =
|
||||||
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
|
!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, [
|
const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [
|
||||||
"guild:",
|
"guild:",
|
||||||
]);
|
]);
|
||||||
@@ -197,7 +201,16 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
const fromLabel = isDirectMessage
|
const fromLabel = isDirectMessage
|
||||||
? buildDirectLabel(message)
|
? 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}]`;
|
const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`;
|
||||||
let combinedBody = formatAgentEnvelope({
|
let combinedBody = formatAgentEnvelope({
|
||||||
surface: "Discord",
|
surface: "Discord",
|
||||||
@@ -238,10 +251,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
: `channel:${message.channelId}`,
|
: `channel:${message.channelId}`,
|
||||||
ChatType: isDirectMessage ? "direct" : "group",
|
ChatType: isDirectMessage ? "direct" : "group",
|
||||||
SenderName: message.member?.displayName ?? message.author.tag,
|
SenderName: message.member?.displayName ?? message.author.tag,
|
||||||
GroupSubject:
|
GroupSubject: groupSubject,
|
||||||
!isDirectMessage && "name" in message.channel
|
|
||||||
? message.channel.name
|
|
||||||
: undefined,
|
|
||||||
Surface: "discord" as const,
|
Surface: "discord" as const,
|
||||||
WasMentioned: wasMentioned,
|
WasMentioned: wasMentioned,
|
||||||
MessageSid: message.id,
|
MessageSid: message.id,
|
||||||
@@ -358,6 +368,13 @@ function buildDirectLabel(message: import("discord.js").Message) {
|
|||||||
return `${username} id:${message.author.id}`;
|
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) {
|
function buildGuildLabel(message: import("discord.js").Message) {
|
||||||
const channelName =
|
const channelName =
|
||||||
"name" in message.channel ? message.channel.name : message.channelId;
|
"name" in message.channel ? message.channel.name : message.channelId;
|
||||||
|
|||||||
@@ -3865,7 +3865,7 @@ describe("gateway server", () => {
|
|||||||
thinkingLevel: "low",
|
thinkingLevel: "low",
|
||||||
verboseLevel: "on",
|
verboseLevel: "on",
|
||||||
},
|
},
|
||||||
"group:dev": {
|
"discord:group:dev": {
|
||||||
sessionId: "sess-group",
|
sessionId: "sess-group",
|
||||||
updatedAt: now - 120_000,
|
updatedAt: now - 120_000,
|
||||||
totalTokens: 50,
|
totalTokens: 50,
|
||||||
@@ -3977,7 +3977,7 @@ describe("gateway server", () => {
|
|||||||
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
|
||||||
ws,
|
ws,
|
||||||
"sessions.delete",
|
"sessions.delete",
|
||||||
{ key: "group:dev" },
|
{ key: "discord:group:dev" },
|
||||||
);
|
);
|
||||||
expect(deleted.ok).toBe(true);
|
expect(deleted.ok).toBe(true);
|
||||||
expect(deleted.payload?.deleted).toBe(true);
|
expect(deleted.payload?.deleted).toBe(true);
|
||||||
@@ -3986,7 +3986,9 @@ describe("gateway server", () => {
|
|||||||
}>(ws, "sessions.list", {});
|
}>(ws, "sessions.list", {});
|
||||||
expect(listAfterDelete.ok).toBe(true);
|
expect(listAfterDelete.ok).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev"),
|
listAfterDelete.payload?.sessions.some(
|
||||||
|
(s) => s.key === "discord:group:dev",
|
||||||
|
),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
const filesAfterDelete = await fs.readdir(dir);
|
const filesAfterDelete = await fs.readdir(dir);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
buildGroupDisplayName,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
@@ -455,6 +456,11 @@ type GatewaySessionsDefaults = {
|
|||||||
type GatewaySessionRow = {
|
type GatewaySessionRow = {
|
||||||
key: string;
|
key: string;
|
||||||
kind: "direct" | "group" | "global" | "unknown";
|
kind: "direct" | "group" | "global" | "unknown";
|
||||||
|
displayName?: string;
|
||||||
|
surface?: string;
|
||||||
|
subject?: string;
|
||||||
|
room?: string;
|
||||||
|
space?: string;
|
||||||
updatedAt: number | null;
|
updatedAt: number | null;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
@@ -862,13 +868,41 @@ function loadSessionEntry(sessionKey: string) {
|
|||||||
return { cfg, storePath, store, entry };
|
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 === "global") return "global";
|
||||||
if (key.startsWith("group:")) return "group";
|
|
||||||
if (key === "unknown") return "unknown";
|
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";
|
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 {
|
function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults {
|
||||||
const resolved = resolveConfiguredModelRef({
|
const resolved = resolveConfiguredModelRef({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -913,9 +947,32 @@ function listSessionsFromStore(params: {
|
|||||||
const input = entry?.inputTokens ?? 0;
|
const input = entry?.inputTokens ?? 0;
|
||||||
const output = entry?.outputTokens ?? 0;
|
const output = entry?.outputTokens ?? 0;
|
||||||
const total = entry?.totalTokens ?? input + output;
|
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 {
|
return {
|
||||||
key,
|
key,
|
||||||
kind: classifySessionKey(key),
|
kind: classifySessionKey(key, entry),
|
||||||
|
displayName,
|
||||||
|
surface,
|
||||||
|
subject,
|
||||||
|
room,
|
||||||
|
space,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
sessionId: entry?.sessionId,
|
sessionId: entry?.sessionId,
|
||||||
systemSent: entry?.systemSent,
|
systemSent: entry?.systemSent,
|
||||||
@@ -2881,6 +2938,12 @@ export async function startGatewayServer(
|
|||||||
verboseLevel: entry?.verboseLevel,
|
verboseLevel: entry?.verboseLevel,
|
||||||
model: entry?.model,
|
model: entry?.model,
|
||||||
contextTokens: entry?.contextTokens,
|
contextTokens: entry?.contextTokens,
|
||||||
|
displayName: entry?.displayName,
|
||||||
|
chatType: entry?.chatType,
|
||||||
|
surface: entry?.surface,
|
||||||
|
subject: entry?.subject,
|
||||||
|
room: entry?.room,
|
||||||
|
space: entry?.space,
|
||||||
lastChannel: entry?.lastChannel,
|
lastChannel: entry?.lastChannel,
|
||||||
lastTo: entry?.lastTo,
|
lastTo: entry?.lastTo,
|
||||||
skillsSnapshot: entry?.skillsSnapshot,
|
skillsSnapshot: entry?.skillsSnapshot,
|
||||||
|
|||||||
@@ -52,6 +52,23 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
|
|||||||
if (!trimmed) throw new Error("iMessage target is required");
|
if (!trimmed) throw new Error("iMessage target is required");
|
||||||
const lower = trimmed.toLowerCase();
|
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) {
|
for (const prefix of CHAT_ID_PREFIXES) {
|
||||||
if (lower.startsWith(prefix)) {
|
if (lower.startsWith(prefix)) {
|
||||||
const value = stripPrefix(trimmed, prefix);
|
const value = stripPrefix(trimmed, prefix);
|
||||||
@@ -89,14 +106,6 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
|
|||||||
return { kind: "chat_guid", chatGuid: value };
|
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" };
|
return { kind: "handle", to: trimmed, service: "auto" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +114,14 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
|
|||||||
if (!trimmed) return { kind: "handle", handle: "" };
|
if (!trimmed) return { kind: "handle", handle: "" };
|
||||||
const lower = trimmed.toLowerCase();
|
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) {
|
for (const prefix of CHAT_ID_PREFIXES) {
|
||||||
if (lower.startsWith(prefix)) {
|
if (lower.startsWith(prefix)) {
|
||||||
const value = stripPrefix(trimmed, prefix);
|
const value = stripPrefix(trimmed, prefix);
|
||||||
|
|||||||
@@ -43,19 +43,20 @@ function parseTarget(raw: string): SignalTarget {
|
|||||||
let value = raw.trim();
|
let value = raw.trim();
|
||||||
if (!value) throw new Error("Signal recipient is required");
|
if (!value) throw new Error("Signal recipient is required");
|
||||||
const lower = value.toLowerCase();
|
const lower = value.toLowerCase();
|
||||||
if (lower.startsWith("group:")) {
|
|
||||||
return { type: "group", groupId: value.slice("group:".length).trim() };
|
|
||||||
}
|
|
||||||
if (lower.startsWith("signal:")) {
|
if (lower.startsWith("signal:")) {
|
||||||
value = value.slice("signal:".length).trim();
|
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 {
|
return {
|
||||||
type: "username",
|
type: "username",
|
||||||
username: value.slice("username:".length).trim(),
|
username: value.slice("username:".length).trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (lower.startsWith("u:")) {
|
if (normalized.startsWith("u:")) {
|
||||||
return { type: "username", username: value.trim() };
|
return { type: "username", username: value.trim() };
|
||||||
}
|
}
|
||||||
return { type: "recipient", recipient: value };
|
return { type: "recipient", recipient: value };
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function normalizeChatId(to: string): string {
|
|||||||
|
|
||||||
// Common internal prefixes that sometimes leak into outbound sends.
|
// Common internal prefixes that sometimes leak into outbound sends.
|
||||||
// - ctx.To uses `telegram:<id>`
|
// - 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();
|
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
|
||||||
|
|
||||||
// Accept t.me links for public chats/channels.
|
// Accept t.me links for public chats/channels.
|
||||||
|
|||||||
@@ -1015,7 +1015,7 @@ describe("web auto-reply", () => {
|
|||||||
.mockResolvedValueOnce({ text: "ok" });
|
.mockResolvedValueOnce({ text: "ok" });
|
||||||
|
|
||||||
const { storePath, cleanup } = await makeSessionStore({
|
const { storePath, cleanup } = await makeSessionStore({
|
||||||
"group:123@g.us": {
|
"whatsapp:group:123@g.us": {
|
||||||
sessionId: "g-1",
|
sessionId: "g-1",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
groupActivation: "always",
|
groupActivation: "always",
|
||||||
|
|||||||
@@ -412,7 +412,10 @@ function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
|
|||||||
const storePath = resolveStorePath(cfg.session?.store);
|
const storePath = resolveStorePath(cfg.session?.store);
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
const isGroupKey = (key: string) =>
|
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 isCronKey = (key: string) => key.startsWith("cron:");
|
||||||
|
|
||||||
const recipients = Object.entries(store)
|
const recipients = Object.entries(store)
|
||||||
@@ -812,7 +815,7 @@ export async function monitorWebProvider(
|
|||||||
const resolveGroupActivationFor = (conversationId: string) => {
|
const resolveGroupActivationFor = (conversationId: string) => {
|
||||||
const key = conversationId.startsWith("group:")
|
const key = conversationId.startsWith("group:")
|
||||||
? conversationId
|
? conversationId
|
||||||
: `group:${conversationId}`;
|
: `whatsapp:group:${conversationId}`;
|
||||||
const store = loadSessionStore(sessionStorePath);
|
const store = loadSessionStore(sessionStorePath);
|
||||||
const entry = store[key];
|
const entry = store[key];
|
||||||
const requireMention = cfg.routing?.groupChat?.requireMention;
|
const requireMention = cfg.routing?.groupChat?.requireMention;
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ export type GatewaySessionsDefaults = {
|
|||||||
export type GatewaySessionRow = {
|
export type GatewaySessionRow = {
|
||||||
key: string;
|
key: string;
|
||||||
kind: "direct" | "group" | "global" | "unknown";
|
kind: "direct" | "group" | "global" | "unknown";
|
||||||
|
displayName?: string;
|
||||||
|
surface?: string;
|
||||||
|
subject?: string;
|
||||||
|
room?: string;
|
||||||
|
space?: string;
|
||||||
updatedAt: number | null;
|
updatedAt: number | null;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
systemSent?: boolean;
|
systemSent?: boolean;
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function renderRow(row: GatewaySessionRow, onPatch: SessionsProps["onPatch"]) {
|
|||||||
const verbose = row.verboseLevel ?? "";
|
const verbose = row.verboseLevel ?? "";
|
||||||
return html`
|
return html`
|
||||||
<div class="table-row">
|
<div class="table-row">
|
||||||
<div class="mono">${row.key}</div>
|
<div class="mono">${row.displayName ?? row.key}</div>
|
||||||
<div>${row.kind}</div>
|
<div>${row.kind}</div>
|
||||||
<div>${updated}</div>
|
<div>${updated}</div>
|
||||||
<div>${formatSessionTokens(row)}</div>
|
<div>${formatSessionTokens(row)}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user