Compare commits

...

27 Commits

Author SHA1 Message Date
Peter Steinberger
ea25d9cc03 docs: changelog for MS Teams scopes (#1507) (thanks @Evizero) 2026-01-24 00:07:03 +00:00
Christof Salis
ade8426cb2 fix(msteams): remove .default suffix from graph scopes
The @microsoft/agents-hosting SDK's MsalTokenProvider automatically
appends `/.default` to all scope strings in its token acquisition
methods (acquireAccessTokenViaSecret, acquireAccessTokenViaFIC,
acquireAccessTokenViaWID, acquireTokenWithCertificate in
msalTokenProvider.ts). This is consistent SDK behavior, not a recent
change.

Our code was including `.default` in scope URLs, resulting in invalid
double suffixes like `https://graph.microsoft.com/.default/.default`.

This was confirmed to cause Graph API authentication errors. Removing
the `.default` suffix from our scope strings allows the SDK to append
it correctly, resolving the issue.

Before: we pass `.default` -> SDK appends -> double `.default` (broken)
After:  we pass base URL  -> SDK appends -> single `.default` (works)
2026-01-24 00:03:20 +00:00
Peter Steinberger
b9c35d9fdc docs: add Comcast SSL troubleshooting note 2026-01-24 00:01:20 +00:00
Peter Steinberger
69f645c662 fix: auto-save voice wake words across apps 2026-01-23 23:59:08 +00:00
Peter Steinberger
efec5fc751 docs: remove channel unify checklist 2026-01-23 23:37:04 +00:00
Peter Steinberger
bf4544784a fix: stabilize typing + summary merge 2026-01-23 23:34:30 +00:00
Peter Steinberger
c9a7c77b24 test: cover typing and history helpers 2026-01-23 23:34:30 +00:00
Peter Steinberger
aeb6b2ffad refactor: standardize channel logging 2026-01-23 23:34:30 +00:00
Peter Steinberger
07ce1d73ff refactor: standardize control command gating 2026-01-23 23:34:30 +00:00
Peter Steinberger
1113f17d4c refactor: share reply prefix context 2026-01-23 23:34:30 +00:00
Peter Steinberger
8252ae2da1 refactor: unify typing callbacks 2026-01-23 23:33:32 +00:00
Peter Steinberger
d82ecaf9dc refactor: centralize inbound session updates 2026-01-23 23:33:32 +00:00
Peter Steinberger
521ea4ae5b refactor: unify pending history helpers 2026-01-23 23:33:32 +00:00
Peter Steinberger
05e7e06146 docs: add channel unification checklist 2026-01-23 23:32:14 +00:00
Peter Steinberger
cb8c8fee9a refactor: centralize ack reaction removal 2026-01-23 23:32:14 +00:00
Peter Steinberger
ed05152cb1 fix: align compaction summary message types 2026-01-23 23:03:04 +00:00
Peter Steinberger
a8054d1e83 fix: complete inbound dispatch refactor 2026-01-23 22:58:54 +00:00
Peter Steinberger
2e0a835e07 fix: unify inbound dispatch pipeline 2026-01-23 22:58:54 +00:00
Peter Steinberger
da26954dd0 test(compaction): cover staged pruning 2026-01-23 22:25:07 +00:00
Peter Steinberger
892197c43e refactor: reuse ack reaction helper for whatsapp 2026-01-23 22:24:31 +00:00
Peter Steinberger
02bd6e4a24 refactor: centralize ack reaction gating 2026-01-23 22:24:31 +00:00
Peter Steinberger
99d4820b39 docs: clarify exe.dev ops 2026-01-23 22:23:23 +00:00
Peter Steinberger
022aa10063 feat(compaction): apply staged pruning 2026-01-23 22:23:23 +00:00
Peter Steinberger
ae0741a346 feat(compaction): add staged helpers 2026-01-23 22:23:23 +00:00
Peter Steinberger
4ee70be690 chore: bump version to 2026.1.23 2026-01-23 22:14:56 +00:00
Shiva Prasad
fdbaae6a33 macOS: fix trigger word input disappearing when typing and on add (#1506)
Fixed issue where trigger words would disappear when typing or when adding new trigger words. The problem was that `swabbleTriggerWords` changes were triggering `VoiceWakeRuntime.refresh()` which sanitized the array by removing empty strings in real-time.

Solution: Introduced local `@State` buffer `triggerEntries` with stable UUID identifiers for each trigger word entry. User edits now only affect the local state buffer and are synced back to `AppState` on explicit actions (submit, remove, disappear). This prevents premature sanitization during editing.

The local state is loaded on view appear and when the view becomes active, ensuring it stays in sync with `AppState`.
2026-01-23 20:08:12 +00:00
Paul van Oorschot
7d0a0ae3ba fix(discord): autoThread ack reactions + exec approval null handling (#1511)
* fix(discord): gate autoThread by thread owner

* fix(discord): ack bot-owned autoThreads

* fix(discord): ack mentions in open channels

- Ack reactions in bot-owned autoThreads
- Ack reactions in open channels (no mention required)
- DRY: Pass pre-computed isAutoThreadOwnedByBot to avoid redundant checks
- Consolidate ack logic with explanatory comment

* fix: allow null values in exec.approval.request schema

The ExecApprovalRequestParamsSchema was rejecting null values for optional
fields like resolvedPath, but the calling code in bash-tools.exec.ts passes
null. This caused intermittent 'invalid exec.approval.request params'
validation errors.

Fix: Accept Type.Union([Type.String(), Type.Null()]) for all optional string
fields in the schema. Update test to reflect new behavior.

* fix: align discord ack reactions with mention gating (#1511) (thanks @pvoo)

---------

Co-authored-by: Wimmie <wimmie@tameson.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-23 20:01:15 +00:00
122 changed files with 3090 additions and 1384 deletions

View File

@@ -23,13 +23,14 @@
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## exe.dev VM ops (general)
- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal).
- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset.
- Restart: exe.dev often lacks systemd user bus; stop old gateway and run:
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
- Restart: stop old gateway and run:
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`.
- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).

View File

@@ -10,6 +10,9 @@ Docs: https://docs.clawd.bot
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
### Fixes
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
@@ -19,6 +22,7 @@ Docs: https://docs.clawd.bot
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
## 2026.1.22

View File

@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601210
versionName = "2026.1.21"
versionCode = 202601230
versionName = "2026.1.23"
}
buildTypes {

View File

@@ -8,10 +8,14 @@ object WakeWords {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
fun parseIfChanged(input: String, current: List<String>): List<String>? {
val parsed = parseCommaSeparated(input)
return if (parsed == current) null else parsed
}
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
val cleaned =
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults }
}
}

View File

@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
@@ -49,7 +51,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode
import com.clawdbot.android.MainViewModel
import com.clawdbot.android.NodeForegroundService
import com.clawdbot.android.VoiceWakeMode
import com.clawdbot.android.WakeWords
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
@@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
var wakeWordsHadFocus by remember { mutableStateOf(false) }
val deviceModel =
remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
@@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val commitWakeWords = {
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
if (parsed != null) {
viewModel.setWakeWords(parsed)
}
}
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
@@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
value = wakeWordsText,
onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") },
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (focusState.isFocused) {
wakeWordsHadFocus = true
} else if (wakeWordsHadFocus) {
wakeWordsHadFocus = false
commitWakeWords()
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
commitWakeWords()
focusManager.clearFocus()
},
),
)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed)
},
enabled = isConnected,
) {
Text("Save + Sync")
}
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
}
}
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
item {
Text(
if (isConnected) {

View File

@@ -1,6 +1,7 @@
package com.clawdbot.android
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class WakeWordsTest {
@@ -32,5 +33,18 @@ class WakeWordsTest {
assertEquals("w1", sanitized.first())
assertEquals("w${WakeWords.maxWords}", sanitized.last())
}
}
@Test
fun parseIfChangedSkipsWhenUnchanged() {
val current = listOf("clawd", "claude")
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
assertNull(parsed)
}
@Test
fun parseIfChangedReturnsUpdatedList() {
val current = listOf("clawd")
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
assertEquals(listOf("clawd", "jarvis"), parsed)
}
}

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.21</string>
<string>2026.1.23</string>
<key>CFBundleVersion</key>
<string>20260121</string>
<string>20260123</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@@ -1,8 +1,10 @@
import SwiftUI
import Combine
struct VoiceWakeWordsSettingsView: View {
@Environment(NodeAppModel.self) private var appModel
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@FocusState private var focusedTriggerIndex: Int?
@State private var syncTask: Task<Void, Never>?
var body: some View {
@@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View {
TextField("Wake word", text: self.binding(for: index))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused(self.$focusedTriggerIndex, equals: index)
.onSubmit {
self.commitTriggerWords()
}
}
.onDelete(perform: self.removeWords)
@@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View {
.onAppear {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
self.commitTriggerWords()
}
}
.onChange(of: self.triggerWords) { _, newValue in
// Keep local voice wake responsive even if the gateway isn't connected yet.
VoiceWakePreferences.saveTriggerWords(newValue)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
guard oldValue != nil, oldValue != newValue else { return }
self.commitTriggerWords()
}
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
guard self.focusedTriggerIndex == nil else { return }
let updated = VoiceWakePreferences.loadTriggerWords()
if updated != self.triggerWords {
self.triggerWords = updated
}
}
}
@@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
}
self.commitTriggerWords()
}
private func binding(for index: Int) -> Binding<String> {
@@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View {
self.triggerWords[index] = newValue
})
}
private func commitTriggerWords() {
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
}
}
}

View File

@@ -6,6 +6,8 @@ enum VoiceWakePreferences {
// Keep defaults aligned with the mac app.
static let defaultTriggerWords: [String] = ["clawd", "claude"]
static let maxWords = 32
static let maxWordLength = 64
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
guard let data = payloadJSON.data(using: .utf8) else { return nil }
@@ -30,6 +32,8 @@ enum VoiceWakePreferences {
let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(Self.maxWords)
.map { String($0.prefix(Self.maxWordLength)) }
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
}

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.21</string>
<string>2026.1.23</string>
<key>CFBundleVersion</key>
<string>20260121</string>
<string>20260123</string>
</dict>
</plist>

View File

@@ -11,6 +11,18 @@ import Testing
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
}
@Test func sanitizeTriggerWordsLimitsWordLength() {
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
}
@Test func sanitizeTriggerWordsLimitsWordCount() {
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
#expect(cleaned.count == VoiceWakePreferences.maxWords)
}
@Test func displayStringUsesSanitizedWords() {
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
}

View File

@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"

View File

@@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
let showDockIconKey = "clawdbot.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMaxWords = 32
let voiceWakeMaxWordLength = 64
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.21</string>
<string>2026.1.23</string>
<key>CFBundleVersion</key>
<string>202601210</string>
<string>202601230</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>

View File

@@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(voiceWakeMaxWords)
.map { String($0.prefix(voiceWakeMaxWordLength)) }
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
}

View File

@@ -21,6 +21,7 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -31,9 +32,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid }
}
private struct IndexedWord: Identifiable {
let id: Int
let value: String
private struct TriggerEntry: Identifiable {
let id: UUID
var value: String
}
private var voiceWakeBinding: Binding<Bool> {
@@ -105,6 +106,7 @@ struct VoiceWakeSettings: View {
.onAppear {
guard !self.isPreview else { return }
self.startMicObserver()
self.loadTriggerEntries()
}
.onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return }
@@ -122,8 +124,10 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
Task { await self.meter.stop() }
self.micObserver.stop()
self.syncTriggerEntriesToState()
} else {
self.startMicObserver()
self.loadTriggerEntries()
}
}
.onDisappear {
@@ -136,11 +140,16 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
self.micObserver.stop()
Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
}
}
private var indexedWords: [IndexedWord] {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
private func loadTriggerEntries() {
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
}
private var triggerTable: some View {
@@ -154,29 +163,42 @@ struct VoiceWakeSettings: View {
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
}
Table(self.indexedWords) {
TableColumn("Word") { row in
TextField("Wake word", text: self.binding(for: row.id))
.textFieldStyle(.roundedBorder)
}
TableColumn("") { row in
Button {
self.removeWord(at: row.id)
} label: {
Image(systemName: "trash")
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
HStack(spacing: 8) {
TextField("Wake word", text: $entry.value)
.textFieldStyle(.roundedBorder)
.onSubmit {
self.syncTriggerEntriesToState()
}
Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
}
.buttonStyle(.borderless)
.help("Remove trigger word")
}
.width(36)
}
.frame(minHeight: 180)
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
@@ -211,24 +233,12 @@ struct VoiceWakeSettings: View {
}
private func addWord() {
self.state.swabbleTriggerWords.append("")
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
}
private func removeWord(at index: Int) {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords.remove(at: index)
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
return self.state.swabbleTriggerWords[index]
},
set: { newValue in
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords[index] = newValue
})
private func removeWord(id: UUID) {
self.triggerEntries.removeAll { $0.id == id }
self.syncTriggerEntriesToState()
}
private func toggleTest() {
@@ -638,13 +648,14 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"]
let view = VoiceWakeSettings(state: state, isActive: true)
var view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42
view.meterError = "No input"
view.testState = .detected("ok")
view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body
_ = view.localePicker
@@ -654,8 +665,9 @@ extension VoiceWakeSettings {
_ = view.chimeSection
view.addWord()
_ = view.binding(for: 0).wrappedValue
view.removeWord(at: 0)
if let entryId = view.triggerEntries.first?.id {
view.removeWord(id: entryId)
}
}
}
#endif

View File

@@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests {
#expect(cleaned == defaultVoiceWakeTriggers)
}
@Test func sanitizeTriggersLimitsWordLength() {
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
#expect(cleaned[1].count == voiceWakeMaxWordLength)
}
@Test func sanitizeTriggersLimitsWordCount() {
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
let cleaned = sanitizeVoiceWakeTriggers(words)
#expect(cleaned.count == voiceWakeMaxWords)
}
@Test func normalizeLocaleStripsCollation() {
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
}

View File

@@ -43,6 +43,14 @@ Almost always a Node/npm PATH issue. Start here:
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Control UI](/web/control-ui#insecure-http)
### `docs.clawd.bot` shows an SSL error (Comcast/Xfinity)
Some Comcast/Xfinity connections block `docs.clawd.bot` via Xfinity Advanced Security.
Disable Advanced Security or add `docs.clawd.bot` to the allowlist, then retry.
- Xfinity Advanced Security help: https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security
- Quick sanity checks: try a mobile hotspot or VPN to confirm it’s ISP-level filtering
### Service says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting)

View File

@@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.21 \
APP_VERSION=2026.1.23 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.21 \
APP_VERSION=2026.1.23 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.21.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`.
- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
import { EventEmitter } from "node:events";
import { removeAckReactionAfterReply, shouldAckReaction } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import {
handleBlueBubblesWebhookRequest,
@@ -128,6 +129,7 @@ function createMockRuntime(): PluginRuntime {
session: {
resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordInboundSession: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
},
@@ -135,6 +137,10 @@ function createMockRuntime(): PluginRuntime {
buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],

View File

@@ -1,7 +1,13 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveAckReaction } from "clawdbot/plugin-sdk";
import {
logAckFailure,
logInboundDrop,
logTypingFailure,
resolveAckReaction,
resolveControlCommandGate,
} from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js";
@@ -1346,23 +1352,25 @@ async function processMessage(
})
: false;
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
const commandAuthorized = isGroup
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
})
: dmAuthorized;
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
allowTextCommands: true,
hasControlCommand: hasControlCmd,
});
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
// Block control commands from unauthorized senders in groups
if (isGroup && hasControlCmd && !commandAuthorized) {
logVerbose(
core,
runtime,
`bluebubbles: drop control command from unauthorized sender ${message.senderId}`,
);
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
reason: "control command (unauthorized)",
target: message.senderId,
});
return;
}
@@ -1521,19 +1529,20 @@ async function processMessage(
core,
runtime,
});
const shouldAckReaction = () => {
if (!ackReactionValue) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return !isGroup;
if (ackReactionScope === "group-all") return isGroup;
if (ackReactionScope === "group-mentions") {
if (!isGroup) return false;
if (!requireMention) return false;
if (!canDetectMention) return false;
return effectiveWasMentioned;
}
return false;
};
const shouldAckReaction = () =>
Boolean(
ackReactionValue &&
core.channel.reactions.shouldAckReaction({
scope: ackReactionScope,
isDirect: !isGroup,
isGroup,
isMentionableGroup: isGroup,
requireMention: Boolean(requireMention),
canDetectMention,
effectiveWasMentioned,
shouldBypassMention,
}),
);
const ackMessageId = message.messageId?.trim() || "";
const ackReactionPromise =
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
@@ -1749,29 +1758,27 @@ async function processMessage(
},
});
} finally {
if (
removeAckAfterReply &&
sentMessage &&
ackReactionPromise &&
ackReactionValue &&
chatGuidForActions &&
ackMessageId
) {
void ackReactionPromise.then((didAck) => {
if (!didAck) return;
sendBlueBubblesReaction({
chatGuid: chatGuidForActions,
messageGuid: ackMessageId,
emoji: ackReactionValue,
remove: true,
opts: { cfg: config, accountId: account.accountId },
}).catch((err) => {
logVerbose(
core,
runtime,
`ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
);
});
if (sentMessage && chatGuidForActions && ackMessageId) {
core.channel.reactions.removeAckReactionAfterReply({
removeAfterReply: removeAckAfterReply,
ackReactionPromise,
ackReactionValue: ackReactionValue ?? null,
remove: () =>
sendBlueBubblesReaction({
chatGuid: chatGuidForActions,
messageGuid: ackMessageId,
emoji: ackReactionValue ?? "",
remove: true,
opts: { cfg: config, accountId: account.accountId },
}),
onError: (err) => {
logAckFailure({
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
target: `${chatGuidForActions}/${ackMessageId}`,
error: err,
});
},
});
}
if (chatGuidForActions && baseUrl && password && !sentMessage) {
@@ -1780,7 +1787,13 @@ async function processMessage(
cfg: config,
accountId: account.accountId,
}).catch((err) => {
logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
logTypingFailure({
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
action: "stop",
target: chatGuidForActions,
error: err,
});
});
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/lobster",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {

View File

@@ -1,5 +1,10 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {

View File

@@ -1,7 +1,12 @@
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
import {
createReplyPrefixContext,
createTypingCallbacks,
formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
resolveControlCommandGate,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { CoreConfig, ReplyToMode } from "../../types.js";
@@ -376,21 +381,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
userName: senderName,
})
: false;
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
});
if (
isRoom &&
allowTextCommands &&
core.channel.text.hasControlCommand(bodyText, cfg) &&
!commandAuthorized
) {
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
const commandAuthorized = commandGate.commandAuthorized;
if (isRoom && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "matrix",
reason: "control command (unauthorized)",
target: senderId,
});
return;
}
const shouldRequireMention = isRoom
@@ -409,7 +418,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
core.channel.text.hasControlCommand(bodyText);
hasControlCommandInMessage;
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return;
@@ -486,47 +496,45 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
OriginatingTo: `room:${roomId}`,
});
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logger.warn(
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
"failed updating session meta",
);
});
if (isDirectMessage) {
await core.channel.session.updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: route.accountId,
ctx: ctxPayload,
});
}
},
});
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackScope === "all") return true;
if (ackScope === "direct") return isDirectMessage;
if (ackScope === "group-all") return isRoom;
if (ackScope === "group-mentions") {
if (!isRoom) return false;
if (!shouldRequireMention) return false;
return wasMentioned || shouldBypassMention;
}
return false;
};
const shouldAckReaction = () =>
Boolean(
ackReaction &&
core.channel.reactions.shouldAckReaction({
scope: ackScope,
isDirect: isDirectMessage,
isGroup: isRoom,
isMentionableGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned: wasMentioned || shouldBypassMention,
shouldBypassMention,
}),
);
if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
@@ -553,10 +561,33 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
channel: "matrix",
accountId: route.accountId,
});
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingMatrix(roomId, true, undefined, client),
stop: () => sendTypingMatrix(roomId, false, undefined, client),
onStartError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
action: "start",
target: roomId,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
action: "stop",
target: roomId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
await deliverMatrixReplies({
@@ -575,10 +606,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onError: (err, info) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: () =>
sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
onIdle: () =>
sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
});
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
@@ -588,6 +617,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected: prefixContext.onModelSelected,
},
});
markDispatchIdle();

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/mattermost",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Mattermost channel plugin",
"clawdbot": {

View File

@@ -7,10 +7,15 @@ import type {
RuntimeEnv,
} from "clawdbot/plugin-sdk";
import {
createReplyPrefixContext,
createTypingCallbacks,
logInboundDrop,
logTypingFailure,
buildPendingHistoryContextFromMap,
clearHistoryEntries,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
resolveChannelMediaMaxBytes,
type HistoryEntry,
} from "clawdbot/plugin-sdk";
@@ -30,12 +35,9 @@ import {
} from "./client.js";
import {
createDedupeCache,
extractShortModelName,
formatInboundFromLabel,
rawDataToString,
resolveIdentityName,
resolveThreadSessionKeys,
type ResponsePrefixContext,
} from "./monitor-helpers.js";
import { sendMessageMattermost } from "./send.js";
@@ -307,11 +309,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
};
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
try {
await sendMattermostTyping(client, { channelId, parentId });
} catch (err) {
logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
}
await sendMattermostTyping(client, { channelId, parentId });
};
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
@@ -403,7 +401,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg,
surface: "mattermost",
});
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
const isControlCommand = allowTextCommands && hasControlCommand;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed({
senderId,
@@ -415,19 +414,20 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderName,
allowFrom: effectiveGroupAllowFrom,
});
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized =
kind === "dm"
? dmPolicy === "open" || senderAllowedForCommands
: core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
});
kind === "dm" ? dmPolicy === "open" || senderAllowedForCommands : commandGate.commandAuthorized;
if (kind === "dm") {
if (dmPolicy === "disabled") {
@@ -488,10 +488,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
}
if (kind !== "dm" && isControlCommand && !commandAuthorized) {
logVerboseMessage(
`mattermost: drop control command from unauthorized sender ${senderId}`,
);
if (kind !== "dm" && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "mattermost",
reason: "control command (unauthorized)",
target: senderId,
});
return;
}
@@ -534,19 +537,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: "");
const pendingSender = senderName;
const recordPendingHistory = () => {
if (!historyKey || historyLimit <= 0) return;
const trimmed = pendingBody.trim();
if (!trimmed) return;
recordPendingHistoryEntry({
recordPendingHistoryEntryIfEnabled({
historyMap: channelHistories,
historyKey,
limit: historyLimit,
entry: {
sender: pendingSender,
body: trimmed,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
messageId: post.id ?? undefined,
},
historyKey: historyKey ?? "",
entry: historyKey && trimmed
? {
sender: pendingSender,
body: trimmed,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
messageId: post.id ?? undefined,
}
: null,
});
};
@@ -623,7 +626,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
sender: { name: senderName, id: senderId },
});
let combinedBody = body;
if (historyKey && historyLimit > 0) {
if (historyKey) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: channelHistories,
historyKey,
@@ -713,15 +716,23 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
});
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
};
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingIndicator(channelId, threadRootId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: channelId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
responsePrefixContextProvider: () => prefixContext,
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
@@ -752,7 +763,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
onError: (err, info) => {
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
onReplyStart: typingCallbacks.onReplyStart,
});
await core.channel.reply.dispatchReplyFromConfig({
@@ -763,17 +774,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected: (ctx) => {
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
onModelSelected: prefixContext.onModelSelected,
},
});
markDispatchIdle();
if (historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: channelHistories, historyKey });
if (historyKey) {
clearHistoryEntriesIfEnabled({ historyMap: channelHistories, historyKey, limit: historyLimit });
}
};

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {

View File

@@ -1,5 +1,10 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {

View File

@@ -68,10 +68,10 @@ function scopeCandidatesForUrl(url: string): string[] {
host.endsWith("1drv.ms") ||
host.includes("sharepoint");
return looksLikeGraph
? ["https://graph.microsoft.com/.default", "https://api.botframework.com/.default"]
: ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
? ["https://graph.microsoft.com", "https://api.botframework.com"]
: ["https://api.botframework.com", "https://graph.microsoft.com"];
} catch {
return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
return ["https://api.botframework.com", "https://graph.microsoft.com"];
}
}

View File

@@ -198,7 +198,7 @@ export async function downloadMSTeamsGraphMedia(params: {
const messageUrl = params.messageUrl;
let accessToken: string;
try {
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
} catch {
return { media: [], messageUrl, tokenError: true };
}

View File

@@ -64,7 +64,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;

View File

@@ -13,7 +13,7 @@ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
const GRAPH_BETA = "https://graph.microsoft.com/beta";
const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
const GRAPH_SCOPE = "https://graph.microsoft.com";
export interface OneDriveUploadResult {
id: string;

View File

@@ -1,8 +1,10 @@
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
logInboundDrop,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
resolveMentionGating,
formatAllowlistMatchMeta,
type HistoryEntry,
@@ -251,15 +253,24 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
senderId,
senderName,
});
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
});
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
const commandAuthorized = commandGate.commandAuthorized;
if (commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "msteams",
reason: "control command (unauthorized)",
target: senderId,
});
return;
}
@@ -371,19 +382,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
requireMention,
mentioned,
});
if (historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: conversationHistories,
historyKey: conversationId,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
});
}
recordPendingHistoryEntryIfEnabled({
historyMap: conversationHistories,
historyKey: conversationId,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
});
return;
}
}
@@ -426,7 +435,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
let combinedBody = body;
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey && historyLimit > 0) {
if (isRoomish && historyKey) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
@@ -467,12 +476,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload,
});
void core.channel.session.recordSessionMetaFromInbound({
storePath,
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
onRecordError: (err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
},
});
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
@@ -512,10 +522,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) {
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
return;
@@ -524,8 +535,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
} catch (err) {
log.error("dispatch failed", { error: String(err) });

View File

@@ -1,4 +1,7 @@
import {
createReplyPrefixContext,
createTypingCallbacks,
logTypingFailure,
resolveChannelMediaMaxBytes,
type ClawdbotConfig,
type MSTeamsReplyStyle,
@@ -39,23 +42,33 @@ export function createMSTeamsReplyDispatcher(params: {
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
try {
await params.context.sendActivities([{ type: "typing" }]);
} catch {
// Typing indicator is best-effort.
}
await params.context.sendActivities([{ type: "typing" }]);
};
return core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(
params.cfg,
params.agentId,
).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator,
onStartError: (err) => {
logTypingFailure({
log: (message) => params.log.debug(message),
channel: "msteams",
action: "start",
error: err,
});
},
});
const prefixContext = createReplyPrefixContext({
cfg: params.cfg,
agentId: params.agentId,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
});
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
@@ -87,21 +100,27 @@ export function createMSTeamsReplyDispatcher(params: {
mediaMaxBytes,
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.(
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
);
params.log.error("reply failed", {
kind: info.kind,
error: errMsg,
classification,
hint,
});
},
onReplyStart: sendTypingIndicator,
});
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.(
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
);
params.log.error("reply failed", {
kind: info.kind,
error: errMsg,
classification,
hint,
});
},
onReplyStart: typingCallbacks.onReplyStart,
});
return {
dispatcher,
replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
markDispatchIdle,
};
}

View File

@@ -143,7 +143,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {

View File

@@ -1,4 +1,9 @@
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import {
logInboundDrop,
resolveControlCommandGate,
type ClawdbotConfig,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import {
@@ -118,7 +123,11 @@ export async function handleNextcloudTalkInbound(params: {
senderId,
senderName,
}).allowed;
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{
@@ -127,7 +136,10 @@ export async function handleNextcloudTalkInbound(params: {
allowed: senderAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = commandGate.commandAuthorized;
if (isGroup) {
const groupAllow = resolveNextcloudTalkGroupAllow({
@@ -188,15 +200,13 @@ export async function handleNextcloudTalkInbound(params: {
}
}
if (
isGroup &&
allowTextCommands &&
core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) &&
commandAuthorized !== true
) {
runtime.log?.(
`nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
);
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: (message) => runtime.log?.(message),
channel: CHANNEL_ID,
reason: "control command (unauthorized)",
target: senderId,
});
return;
}
@@ -212,10 +222,6 @@ export async function handleNextcloudTalkInbound(params: {
wildcardConfig: roomMatch.wildcardConfig,
})
: false;
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const mentionGate = resolveNextcloudTalkMentionGate({
isGroup,
requireMention: shouldRequireMention,
@@ -287,15 +293,14 @@ export async function handleNextcloudTalkInbound(params: {
CommandAuthorized: commandAuthorized,
});
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
});
},
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,

View File

@@ -1,5 +1,10 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {

View File

@@ -1,9 +1,11 @@
{
"name": "@clawdbot/open-prose",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {

View File

@@ -1,5 +1,10 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {

View File

@@ -1,5 +1,10 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {

View File

@@ -570,12 +570,13 @@ async function processMessageWithPipeline(params: {
OriginatingTo: `zalo:${chatId}`,
});
void core.channel.session.recordSessionMetaFromInbound({
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
onRecordError: (err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
},
});
const tableMode = core.channel.text.resolveMarkdownTableMode({

View File

@@ -1,5 +1,10 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {

View File

@@ -311,12 +311,13 @@ async function processMessage(
OriginatingTo: `zalouser:${chatId}`,
});
void core.channel.session.recordSessionMetaFromInbound({
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
onRecordError: (err) => {
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
},
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({

View File

@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.22",
"version": "2026.1.23",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",

View File

@@ -0,0 +1,107 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
estimateMessagesTokens,
pruneHistoryForContextShare,
splitMessagesByTokenShare,
} from "./compaction.js";
function makeMessage(id: number, size: number): AgentMessage {
return {
role: "user",
content: "x".repeat(size),
timestamp: id,
};
}
describe("splitMessagesByTokenShare", () => {
it("splits messages into two non-empty parts", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
];
const parts = splitMessagesByTokenShare(messages, 2);
expect(parts.length).toBeGreaterThanOrEqual(2);
expect(parts[0]?.length).toBeGreaterThan(0);
expect(parts[1]?.length).toBeGreaterThan(0);
expect(parts.flat().length).toBe(messages.length);
});
it("preserves message order across parts", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
makeMessage(5, 4000),
makeMessage(6, 4000),
];
const parts = splitMessagesByTokenShare(messages, 3);
expect(parts.flat().map((msg) => msg.timestamp)).toEqual(messages.map((msg) => msg.timestamp));
});
});
describe("pruneHistoryForContextShare", () => {
it("drops older chunks until the history budget is met", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
];
const maxContextTokens = 2000; // budget is 1000 tokens (50%)
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
expect(pruned.droppedChunks).toBeGreaterThan(0);
expect(pruned.keptTokens).toBeLessThanOrEqual(Math.floor(maxContextTokens * 0.5));
expect(pruned.messages.length).toBeGreaterThan(0);
});
it("keeps the newest messages when pruning", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
makeMessage(5, 4000),
makeMessage(6, 4000),
];
const totalTokens = estimateMessagesTokens(messages);
const maxContextTokens = Math.max(1, Math.floor(totalTokens * 0.5)); // budget = 25%
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
const keptIds = pruned.messages.map((msg) => msg.timestamp);
const expectedSuffix = messages.slice(-keptIds.length).map((msg) => msg.timestamp);
expect(keptIds).toEqual(expectedSuffix);
});
it("keeps history when already within budget", () => {
const messages: AgentMessage[] = [makeMessage(1, 1000)];
const maxContextTokens = 2000;
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
expect(pruned.droppedChunks).toBe(0);
expect(pruned.messages.length).toBe(messages.length);
expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
});
});

341
src/agents/compaction.ts Normal file
View File

@@ -0,0 +1,341 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
export const BASE_CHUNK_RATIO = 0.4;
export const MIN_CHUNK_RATIO = 0.15;
export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
const DEFAULT_SUMMARY_FALLBACK = "No prior history.";
const DEFAULT_PARTS = 2;
const MERGE_SUMMARIES_INSTRUCTIONS =
"Merge these partial summaries into a single cohesive summary. Preserve decisions," +
" TODOs, open questions, and any constraints.";
export function estimateMessagesTokens(messages: AgentMessage[]): number {
return messages.reduce((sum, message) => sum + estimateTokens(message), 0);
}
function normalizeParts(parts: number, messageCount: number): number {
if (!Number.isFinite(parts) || parts <= 1) return 1;
return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount));
}
export function splitMessagesByTokenShare(
messages: AgentMessage[],
parts = DEFAULT_PARTS,
): AgentMessage[][] {
if (messages.length === 0) return [];
const normalizedParts = normalizeParts(parts, messages.length);
if (normalizedParts <= 1) return [messages];
const totalTokens = estimateMessagesTokens(messages);
const targetTokens = totalTokens / normalizedParts;
const chunks: AgentMessage[][] = [];
let current: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (
chunks.length < normalizedParts - 1 &&
current.length > 0 &&
currentTokens + messageTokens > targetTokens
) {
chunks.push(current);
current = [];
currentTokens = 0;
}
current.push(message);
currentTokens += messageTokens;
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
export function chunkMessagesByMaxTokens(
messages: AgentMessage[],
maxTokens: number,
): AgentMessage[][] {
if (messages.length === 0) return [];
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(message);
currentTokens += messageTokens;
if (messageTokens > maxTokens) {
// Split oversized messages to avoid unbounded chunk growth.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Compute adaptive chunk ratio based on average message size.
* When messages are large, we use smaller chunks to avoid exceeding model limits.
*/
export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
if (messages.length === 0) return BASE_CHUNK_RATIO;
const totalTokens = estimateMessagesTokens(messages);
const avgTokens = totalTokens / messages.length;
// Apply safety margin to account for estimation inaccuracy
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
const avgRatio = safeAvgTokens / contextWindow;
// If average message is > 10% of context, reduce chunk ratio
if (avgRatio > 0.1) {
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
}
return BASE_CHUNK_RATIO;
}
/**
* Check if a single message is too large to summarize.
* If single message > 50% of context, it can't be summarized safely.
*/
export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
return tokens > contextWindow * 0.5;
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
const chunks = chunkMessagesByMaxTokens(params.messages, params.maxChunkTokens);
let summary = params.previousSummary;
for (const chunk of chunks) {
summary = await generateSummary(
chunk,
params.model,
params.reserveTokens,
params.apiKey,
params.signal,
params.customInstructions,
summary,
);
}
return summary ?? DEFAULT_SUMMARY_FALLBACK;
}
/**
* Summarize with progressive fallback for handling oversized messages.
* If full summarization fails, tries partial summarization excluding oversized messages.
*/
export async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
const { messages, contextWindow } = params;
if (messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
// Try full summarization first
try {
return await summarizeChunks(params);
} catch (fullError) {
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`,
);
}
// Fallback 1: Summarize only small messages, note oversized ones
const smallMessages: AgentMessage[] = [];
const oversizedNotes: string[] = [];
for (const msg of messages) {
if (isOversizedForSummary(msg, contextWindow)) {
const role = (msg as { role?: string }).role ?? "message";
const tokens = estimateTokens(msg);
oversizedNotes.push(
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
);
} else {
smallMessages.push(msg);
}
}
if (smallMessages.length > 0) {
try {
const partialSummary = await summarizeChunks({
...params,
messages: smallMessages,
});
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)
}`,
);
}
}
// Final fallback: Just note what was there
return (
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
`Summary unavailable due to size limits.`
);
}
export async function summarizeInStages(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
parts?: number;
minMessagesForSplit?: number;
}): Promise<string> {
const { messages } = params;
if (messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
const minMessagesForSplit = Math.max(2, params.minMessagesForSplit ?? 4);
const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, messages.length);
const totalTokens = estimateMessagesTokens(messages);
if (parts <= 1 || messages.length < minMessagesForSplit || totalTokens <= params.maxChunkTokens) {
return summarizeWithFallback(params);
}
const splits = splitMessagesByTokenShare(messages, parts).filter((chunk) => chunk.length > 0);
if (splits.length <= 1) {
return summarizeWithFallback(params);
}
const partialSummaries: string[] = [];
for (const chunk of splits) {
partialSummaries.push(
await summarizeWithFallback({
...params,
messages: chunk,
previousSummary: undefined,
}),
);
}
if (partialSummaries.length === 1) {
return partialSummaries[0];
}
const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({
role: "user",
content: summary,
timestamp: Date.now(),
}));
const mergeInstructions = params.customInstructions
? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\nAdditional focus:\n${params.customInstructions}`
: MERGE_SUMMARIES_INSTRUCTIONS;
return summarizeWithFallback({
...params,
messages: summaryMessages,
customInstructions: mergeInstructions,
});
}
export function pruneHistoryForContextShare(params: {
messages: AgentMessage[];
maxContextTokens: number;
maxHistoryShare?: number;
parts?: number;
}): {
messages: AgentMessage[];
droppedChunks: number;
droppedMessages: number;
droppedTokens: number;
keptTokens: number;
budgetTokens: number;
} {
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
let keptMessages = params.messages;
let droppedChunks = 0;
let droppedMessages = 0;
let droppedTokens = 0;
const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, keptMessages.length);
while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) {
const chunks = splitMessagesByTokenShare(keptMessages, parts);
if (chunks.length <= 1) break;
const [dropped, ...rest] = chunks;
droppedChunks += 1;
droppedMessages += dropped.length;
droppedTokens += estimateMessagesTokens(dropped);
keptMessages = rest.flat();
}
return {
messages: keptMessages,
droppedChunks,
droppedMessages,
droppedTokens,
keptTokens: estimateMessagesTokens(keptMessages),
budgetTokens,
};
}
export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number {
return Math.max(1, Math.floor(model?.contextWindow ?? DEFAULT_CONTEXT_TOKENS));
}

View File

@@ -1,12 +1,16 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionAPI, ExtensionContext, FileOperations } from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
const BASE_CHUNK_RATIO = 0.4;
const MIN_CHUNK_RATIO = 0.15;
const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
import {
BASE_CHUNK_RATIO,
MIN_CHUNK_RATIO,
SAFETY_MARGIN,
computeAdaptiveChunkRatio,
estimateMessagesTokens,
isOversizedForSummary,
pruneHistoryForContextShare,
resolveContextWindowTokens,
summarizeInStages,
} from "../compaction.js";
const FALLBACK_SUMMARY =
"Summary unavailable due to context limits. Older messages were truncated.";
const TURN_PREFIX_INSTRUCTIONS =
@@ -129,175 +133,6 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str
return `\n\n${sections.join("\n\n")}`;
}
function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessage[][] {
if (messages.length === 0) return [];
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(message);
currentTokens += messageTokens;
if (messageTokens > maxTokens) {
// Split oversized messages to avoid unbounded chunk growth.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Compute adaptive chunk ratio based on average message size.
* When messages are large, we use smaller chunks to avoid exceeding model limits.
*/
function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
if (messages.length === 0) return BASE_CHUNK_RATIO;
const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0);
const avgTokens = totalTokens / messages.length;
// Apply safety margin to account for estimation inaccuracy
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
const avgRatio = safeAvgTokens / contextWindow;
// If average message is > 10% of context, reduce chunk ratio
if (avgRatio > 0.1) {
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
}
return BASE_CHUNK_RATIO;
}
/**
* Check if a single message is too large to summarize.
* If single message > 50% of context, it can't be summarized safely.
*/
function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
return tokens > contextWindow * 0.5;
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? "No prior history.";
}
const chunks = chunkMessages(params.messages, params.maxChunkTokens);
let summary = params.previousSummary;
for (const chunk of chunks) {
summary = await generateSummary(
chunk,
params.model,
params.reserveTokens,
params.apiKey,
params.signal,
params.customInstructions,
summary,
);
}
return summary ?? "No prior history.";
}
/**
* Summarize with progressive fallback for handling oversized messages.
* If full summarization fails, tries partial summarization excluding oversized messages.
*/
async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
const { messages, contextWindow } = params;
if (messages.length === 0) {
return params.previousSummary ?? "No prior history.";
}
// Try full summarization first
try {
return await summarizeChunks(params);
} catch (fullError) {
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`,
);
}
// Fallback 1: Summarize only small messages, note oversized ones
const smallMessages: AgentMessage[] = [];
const oversizedNotes: string[] = [];
for (const msg of messages) {
if (isOversizedForSummary(msg, contextWindow)) {
const role = (msg as { role?: string }).role ?? "message";
const tokens = estimateTokens(msg);
oversizedNotes.push(
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
);
} else {
smallMessages.push(msg);
}
}
if (smallMessages.length > 0) {
try {
const partialSummary = await summarizeChunks({
...params,
messages: smallMessages,
});
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)
}`,
);
}
}
// Final fallback: Just note what was there
return (
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
`Summary unavailable due to size limits.`
);
}
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions, signal } = event;
@@ -335,19 +170,48 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
}
try {
const contextWindowTokens = Math.max(
1,
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
);
const contextWindowTokens = resolveContextWindowTokens(model);
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
let messagesToSummarize = preparation.messagesToSummarize;
const tokensBefore =
typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore)
? preparation.tokensBefore
: undefined;
if (tokensBefore !== undefined) {
const summarizableTokens =
estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5);
if (newContentTokens > maxHistoryTokens) {
const pruned = pruneHistoryForContextShare({
messages: messagesToSummarize,
maxContextTokens: contextWindowTokens,
maxHistoryShare: 0.5,
parts: 2,
});
if (pruned.droppedChunks > 0) {
const newContentRatio = (newContentTokens / contextWindowTokens) * 100;
console.warn(
`Compaction safeguard: new content uses ${newContentRatio.toFixed(
1,
)}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` +
`(${pruned.droppedMessages} messages) to fit history budget.`,
);
messagesToSummarize = pruned.messages;
}
}
}
// Use adaptive chunk ratio based on message sizes
const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
const historySummary = await summarizeWithFallback({
messages: preparation.messagesToSummarize,
const historySummary = await summarizeInStages({
messages: messagesToSummarize,
model,
apiKey,
signal,
@@ -359,9 +223,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
});
let summary = historySummary;
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
const prefixSummary = await summarizeWithFallback({
messages: preparation.turnPrefixMessages,
if (preparation.isSplitTurn && turnPrefixMessages.length > 0) {
const prefixSummary = await summarizeInStages({
messages: turnPrefixMessages,
model,
apiKey,
signal,
@@ -369,6 +233,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
maxChunkTokens,
contextWindow: contextWindowTokens,
customInstructions: TURN_PREFIX_INSTRUCTIONS,
previousSummary: undefined,
});
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
}

View File

@@ -0,0 +1,77 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { FinalizedMsgContext, MsgContext } from "./templating.js";
import type { GetReplyOptions } from "./types.js";
import { finalizeInboundContext } from "./reply/inbound-context.js";
import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.js";
import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js";
import {
createReplyDispatcher,
createReplyDispatcherWithTyping,
type ReplyDispatcher,
type ReplyDispatcherOptions,
type ReplyDispatcherWithTypingOptions,
} from "./reply/reply-dispatcher.js";
export type DispatchInboundResult = DispatchFromConfigResult;
export async function dispatchInboundMessage(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcher: ReplyDispatcher;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const finalized = finalizeInboundContext(params.ctx);
return await dispatchReplyFromConfig({
ctx: finalized,
cfg: params.cfg,
dispatcher: params.dispatcher,
replyOptions: params.replyOptions,
replyResolver: params.replyResolver,
});
}
export async function dispatchInboundMessageWithBufferedDispatcher(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherWithTypingOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
const result = await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: {
...params.replyOptions,
...replyOptions,
},
});
markDispatchIdle();
return result;
}
export async function dispatchInboundMessageWithDispatcher(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const dispatcher = createReplyDispatcher(params.dispatcherOptions);
const result = await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: params.replyOptions,
});
await dispatcher.waitForIdle();
return result;
}

View File

@@ -82,7 +82,8 @@ export async function runAgentTurnWithFallback(params: {
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
const directlySentBlockKeys = new Set<string>();
const runId = crypto.randomUUID();
const runId = params.opts?.runId ?? crypto.randomUUID();
params.opts?.onAgentRunStart?.(runId);
if (params.sessionKey) {
registerAgentRunContext(runId, {
sessionKey: params.sessionKey,
@@ -174,6 +175,7 @@ export async function runAgentTurnWithFallback(params: {
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
cliSessionId,
images: params.opts?.images,
})
.then((result) => {
emitAgentEvent({
@@ -248,6 +250,8 @@ export async function runAgentTurnWithFallback(params: {
bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs,
runId,
images: params.opts?.images,
abortSignal: params.opts?.abortSignal,
blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: allowPartialStream

View File

@@ -5,7 +5,9 @@ import {
buildHistoryContextFromEntries,
buildHistoryContextFromMap,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
HISTORY_CONTEXT_MARKER,
recordPendingHistoryEntryIfEnabled,
} from "./history.js";
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
@@ -105,4 +107,46 @@ describe("history helpers", () => {
expect(result).toContain(CURRENT_MESSAGE_MARKER);
expect(result).toContain("current");
});
it("records pending entries only when enabled", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>();
recordPendingHistoryEntryIfEnabled({
historyMap,
historyKey: "group",
limit: 0,
entry: { sender: "A", body: "one" },
});
expect(historyMap.get("group")).toEqual(undefined);
recordPendingHistoryEntryIfEnabled({
historyMap,
historyKey: "group",
limit: 2,
entry: null,
});
expect(historyMap.get("group")).toEqual(undefined);
recordPendingHistoryEntryIfEnabled({
historyMap,
historyKey: "group",
limit: 2,
entry: { sender: "B", body: "two" },
});
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]);
});
it("clears history entries only when enabled", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>();
historyMap.set("group", [
{ sender: "A", body: "one" },
{ sender: "B", body: "two" },
]);
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 });
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]);
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 });
expect(historyMap.get("group")).toEqual([]);
});
});

View File

@@ -47,6 +47,22 @@ export function recordPendingHistoryEntry<T extends HistoryEntry>(params: {
return appendHistoryEntry(params);
}
export function recordPendingHistoryEntryIfEnabled<T extends HistoryEntry>(params: {
historyMap: Map<string, T[]>;
historyKey: string;
entry?: T | null;
limit: number;
}): T[] {
if (!params.entry) return [];
if (params.limit <= 0) return [];
return recordPendingHistoryEntry({
historyMap: params.historyMap,
historyKey: params.historyKey,
entry: params.entry,
limit: params.limit,
});
}
export function buildPendingHistoryContextFromMap(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
@@ -101,6 +117,15 @@ export function clearHistoryEntries(params: {
params.historyMap.set(params.historyKey, []);
}
export function clearHistoryEntriesIfEnabled(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
limit: number;
}): void {
if (params.limit <= 0) return;
clearHistoryEntries({ historyMap: params.historyMap, historyKey: params.historyKey });
}
export function buildHistoryContextFromEntries(params: {
entries: HistoryEntry[];
currentMessage: string;

View File

@@ -1,58 +1,44 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { FinalizedMsgContext } from "../templating.js";
import type { FinalizedMsgContext, MsgContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js";
import type { DispatchFromConfigResult } from "./dispatch-from-config.js";
import { dispatchReplyFromConfig } from "./dispatch-from-config.js";
import type { DispatchInboundResult } from "../dispatch.js";
import {
createReplyDispatcher,
createReplyDispatcherWithTyping,
type ReplyDispatcherOptions,
type ReplyDispatcherWithTypingOptions,
dispatchInboundMessageWithBufferedDispatcher,
dispatchInboundMessageWithDispatcher,
} from "../dispatch.js";
import type {
ReplyDispatcherOptions,
ReplyDispatcherWithTypingOptions,
} from "./reply-dispatcher.js";
export async function dispatchReplyWithBufferedBlockDispatcher(params: {
ctx: FinalizedMsgContext;
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherWithTypingOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
}): Promise<DispatchFromConfigResult> {
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
const result = await dispatchReplyFromConfig({
}): Promise<DispatchInboundResult> {
return await dispatchInboundMessageWithBufferedDispatcher({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
dispatcherOptions: params.dispatcherOptions,
replyResolver: params.replyResolver,
replyOptions: {
...params.replyOptions,
...replyOptions,
},
replyOptions: params.replyOptions,
});
markDispatchIdle();
return result;
}
export async function dispatchReplyWithDispatcher(params: {
ctx: FinalizedMsgContext;
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
}): Promise<DispatchFromConfigResult> {
const dispatcher = createReplyDispatcher(params.dispatcherOptions);
const result = await dispatchReplyFromConfig({
}): Promise<DispatchInboundResult> {
return await dispatchInboundMessageWithDispatcher({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
dispatcherOptions: params.dispatcherOptions,
replyResolver: params.replyResolver,
replyOptions: params.replyOptions,
});
await dispatcher.waitForIdle();
return result;
}

View File

@@ -1,3 +1,4 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { TypingController } from "./reply/typing.js";
export type BlockReplyContext = {
@@ -13,6 +14,14 @@ export type ModelSelectedContext = {
};
export type GetReplyOptions = {
/** Override run id for agent events (defaults to random UUID). */
runId?: string;
/** Abort signal for the underlying agent run. */
abortSignal?: AbortSignal;
/** Optional inbound images (used for webchat attachments). */
images?: ImageContent[];
/** Notifies when an agent run actually starts (useful for webchat command handling). */
onAgentRunStart?: (runId: string) => void;
onReplyStart?: () => Promise<void> | void;
onTypingController?: (typing: TypingController) => void;
isHeartbeat?: boolean;

View File

@@ -0,0 +1,269 @@
import { describe, expect, it, vi } from "vitest";
import {
removeAckReactionAfterReply,
shouldAckReaction,
shouldAckReactionForWhatsApp,
} from "./ack-reactions.js";
describe("shouldAckReaction", () => {
it("honors direct and group-all scopes", () => {
expect(
shouldAckReaction({
scope: "direct",
isDirect: true,
isGroup: false,
isMentionableGroup: false,
requireMention: false,
canDetectMention: false,
effectiveWasMentioned: false,
}),
).toBe(true);
expect(
shouldAckReaction({
scope: "group-all",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: false,
canDetectMention: false,
effectiveWasMentioned: false,
}),
).toBe(true);
});
it("skips when scope is off or none", () => {
expect(
shouldAckReaction({
scope: "off",
isDirect: true,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "none",
isDirect: true,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
});
it("defaults to group-mentions gating", () => {
expect(
shouldAckReaction({
scope: undefined,
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(true);
});
it("requires mention gating for group-mentions", () => {
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: false,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: false,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: false,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(true);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: false,
shouldBypassMention: true,
}),
).toBe(true);
});
});
describe("shouldAckReactionForWhatsApp", () => {
it("respects direct and group modes", () => {
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: true,
isGroup: false,
directEnabled: true,
groupMode: "mentions",
wasMentioned: false,
groupActivated: false,
}),
).toBe(true);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: true,
isGroup: false,
directEnabled: false,
groupMode: "mentions",
wasMentioned: false,
groupActivated: false,
}),
).toBe(false);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "always",
wasMentioned: false,
groupActivated: false,
}),
).toBe(true);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "never",
wasMentioned: true,
groupActivated: true,
}),
).toBe(false);
});
it("honors mentions or activation for group-mentions", () => {
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "mentions",
wasMentioned: true,
groupActivated: false,
}),
).toBe(true);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "mentions",
wasMentioned: false,
groupActivated: true,
}),
).toBe(true);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "mentions",
wasMentioned: false,
groupActivated: false,
}),
).toBe(false);
});
});
describe("removeAckReactionAfterReply", () => {
it("removes only when ack succeeded", async () => {
const remove = vi.fn().mockResolvedValue(undefined);
const onError = vi.fn();
removeAckReactionAfterReply({
removeAfterReply: true,
ackReactionPromise: Promise.resolve(true),
ackReactionValue: "👀",
remove,
onError,
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(remove).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
});
it("skips removal when ack did not happen", async () => {
const remove = vi.fn().mockResolvedValue(undefined);
removeAckReactionAfterReply({
removeAfterReply: true,
ackReactionPromise: Promise.resolve(false),
ackReactionValue: "👀",
remove,
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(remove).not.toHaveBeenCalled();
});
it("skips when not configured", async () => {
const remove = vi.fn().mockResolvedValue(undefined);
removeAckReactionAfterReply({
removeAfterReply: false,
ackReactionPromise: Promise.resolve(true),
ackReactionValue: "👀",
remove,
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(remove).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,71 @@
export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" | "off" | "none";
export type WhatsAppAckReactionMode = "always" | "mentions" | "never";
export type AckReactionGateParams = {
scope: AckReactionScope | undefined;
isDirect: boolean;
isGroup: boolean;
isMentionableGroup: boolean;
requireMention: boolean;
canDetectMention: boolean;
effectiveWasMentioned: boolean;
shouldBypassMention?: boolean;
};
export function shouldAckReaction(params: AckReactionGateParams): boolean {
const scope = params.scope ?? "group-mentions";
if (scope === "off" || scope === "none") return false;
if (scope === "all") return true;
if (scope === "direct") return params.isDirect;
if (scope === "group-all") return params.isGroup;
if (scope === "group-mentions") {
if (!params.isMentionableGroup) return false;
if (!params.requireMention) return false;
if (!params.canDetectMention) return false;
return params.effectiveWasMentioned || params.shouldBypassMention === true;
}
return false;
}
export function shouldAckReactionForWhatsApp(params: {
emoji: string;
isDirect: boolean;
isGroup: boolean;
directEnabled: boolean;
groupMode: WhatsAppAckReactionMode;
wasMentioned: boolean;
groupActivated: boolean;
}): boolean {
if (!params.emoji) return false;
if (params.isDirect) return params.directEnabled;
if (!params.isGroup) return false;
if (params.groupMode === "never") return false;
if (params.groupMode === "always") return true;
return shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: params.wasMentioned,
shouldBypassMention: params.groupActivated,
});
}
export function removeAckReactionAfterReply(params: {
removeAfterReply: boolean;
ackReactionPromise: Promise<boolean> | null;
ackReactionValue: string | null;
remove: () => Promise<void>;
onError?: (err: unknown) => void;
}) {
if (!params.removeAfterReply) return;
if (!params.ackReactionPromise) return;
if (!params.ackReactionValue) return;
void params.ackReactionPromise.then((didAck) => {
if (!didAck) return;
params.remove().catch((err) => params.onError?.(err));
});
}

33
src/channels/logging.ts Normal file
View File

@@ -0,0 +1,33 @@
export type LogFn = (message: string) => void;
export function logInboundDrop(params: {
log: LogFn;
channel: string;
reason: string;
target?: string;
}): void {
const target = params.target ? ` target=${params.target}` : "";
params.log(`${params.channel}: drop ${params.reason}${target}`);
}
export function logTypingFailure(params: {
log: LogFn;
channel: string;
target?: string;
action?: "start" | "stop";
error: unknown;
}): void {
const target = params.target ? ` target=${params.target}` : "";
const action = params.action ? ` action=${params.action}` : "";
params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`);
}
export function logAckFailure(params: {
log: LogFn;
channel: string;
target?: string;
error: unknown;
}): void {
const target = params.target ? ` target=${params.target}` : "";
params.log(`${params.channel} ack cleanup failed${target}: ${String(params.error)}`);
}

View File

@@ -0,0 +1,41 @@
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { GetReplyOptions } from "../auto-reply/types.js";
import {
extractShortModelName,
type ResponsePrefixContext,
} from "../auto-reply/reply/response-prefix-template.js";
type ModelSelectionContext = Parameters<NonNullable<GetReplyOptions["onModelSelected"]>>[0];
export type ReplyPrefixContextBundle = {
prefixContext: ResponsePrefixContext;
responsePrefix?: string;
responsePrefixContextProvider: () => ResponsePrefixContext;
onModelSelected: (ctx: ModelSelectionContext) => void;
};
export function createReplyPrefixContext(params: {
cfg: ClawdbotConfig;
agentId: string;
}): ReplyPrefixContextBundle {
const { cfg, agentId } = params;
const prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, agentId),
};
const onModelSelected = (ctx: ModelSelectionContext) => {
// Mutate the object directly instead of reassigning to ensure closures see updates.
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
};
return {
prefixContext,
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
onModelSelected,
};
}

49
src/channels/session.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { MsgContext } from "../auto-reply/templating.js";
import {
recordSessionMetaFromInbound,
type GroupKeyResolution,
type SessionEntry,
updateLastRoute,
} from "../config/sessions.js";
export type InboundLastRouteUpdate = {
sessionKey: string;
channel: SessionEntry["lastChannel"];
to: string;
accountId?: string;
threadId?: string | number;
};
export async function recordInboundSession(params: {
storePath: string;
sessionKey: string;
ctx: MsgContext;
groupResolution?: GroupKeyResolution | null;
createIfMissing?: boolean;
updateLastRoute?: InboundLastRouteUpdate;
onRecordError: (err: unknown) => void;
}): Promise<void> {
const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params;
void recordSessionMetaFromInbound({
storePath,
sessionKey,
ctx,
groupResolution,
createIfMissing,
}).catch(params.onRecordError);
const update = params.updateLastRoute;
if (!update) return;
await updateLastRoute({
storePath,
sessionKey: update.sessionKey,
deliveryContext: {
channel: update.channel,
to: update.to,
accountId: update.accountId,
threadId: update.threadId,
},
ctx,
groupResolution,
});
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { createTypingCallbacks } from "./typing.js";
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("createTypingCallbacks", () => {
it("invokes start on reply start", async () => {
const start = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, onStartError });
await callbacks.onReplyStart();
expect(start).toHaveBeenCalledTimes(1);
expect(onStartError).not.toHaveBeenCalled();
});
it("reports start errors", async () => {
const start = vi.fn().mockRejectedValue(new Error("fail"));
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, onStartError });
await callbacks.onReplyStart();
expect(onStartError).toHaveBeenCalledTimes(1);
});
it("invokes stop on idle and reports stop errors", async () => {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockRejectedValue(new Error("stop"));
const onStartError = vi.fn();
const onStopError = vi.fn();
const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
callbacks.onIdle?.();
await flush();
expect(stop).toHaveBeenCalledTimes(1);
expect(onStopError).toHaveBeenCalledTimes(1);
});
});

28
src/channels/typing.ts Normal file
View File

@@ -0,0 +1,28 @@
export type TypingCallbacks = {
onReplyStart: () => Promise<void>;
onIdle?: () => void;
};
export function createTypingCallbacks(params: {
start: () => Promise<void>;
stop?: () => Promise<void>;
onStartError: (err: unknown) => void;
onStopError?: (err: unknown) => void;
}): TypingCallbacks {
const stop = params.stop;
const onReplyStart = async () => {
try {
await params.start();
} catch (err) {
params.onStartError(err);
}
};
const onIdle = stop
? () => {
void stop().catch((err) => (params.onStopError ?? params.onStartError)(err));
}
: undefined;
return { onReplyStart, onIdle };
}

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
const dispatchMock = vi.fn();
@@ -20,15 +21,34 @@ vi.mock("@buape/carbon", () => ({
},
}));
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
}));
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
return {
...actual,
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
};
});
beforeEach(() => {
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
dispatcher.sendToolResult({ text: "tool update" });
dispatcher.sendFinalReply({ text: "final reply" });
return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
dispatchMock.mockReset().mockImplementation(async (params) => {
if ("dispatcher" in params && params.dispatcher) {
params.dispatcher.sendToolResult({ text: "tool update" });
params.dispatcher.sendFinalReply({ text: "final reply" });
return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
}
if ("dispatcherOptions" in params && params.dispatcherOptions) {
const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
dispatcher.sendToolResult({ text: "tool update" });
dispatcher.sendFinalReply({ text: "final reply" });
await dispatcher.waitForIdle();
markDispatchIdle();
return { queuedFinal: true, counts: dispatcher.getQueuedCounts() };
}
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
});
});

View File

@@ -377,12 +377,63 @@ describe("discord mention gating", () => {
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
threadOwnerId: "bot123",
channelConfig,
guildInfo,
}),
).toBe(false);
});
it("requires mention inside user-created threads with autoThread enabled", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
threadOwnerId: "user456",
channelConfig,
guildInfo,
}),
).toBe(true);
});
it("requires mention when thread owner is unknown", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
channelConfig,
guildInfo,
}),
).toBe(true);
});
it("inherits parent channel mention rules for threads", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,

View File

@@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({
reactMock(...args);
},
}));
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
}));
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
return {
...actual,
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
};
});
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
@@ -41,7 +47,7 @@ beforeEach(() => {
updateLastRouteMock.mockReset();
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });

View File

@@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({
reactMock(...args);
},
}));
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
}));
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
return {
...actual,
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
};
});
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
@@ -40,7 +46,7 @@ beforeEach(() => {
updateLastRouteMock.mockReset();
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });

View File

@@ -282,14 +282,33 @@ export function resolveDiscordChannelConfigWithFallback(params: {
export function resolveDiscordShouldRequireMention(params: {
isGuildMessage: boolean;
isThread: boolean;
botId?: string | null;
threadOwnerId?: string | null;
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
/** Pass pre-computed value to avoid redundant checks. */
isAutoThreadOwnedByBot?: boolean;
}): boolean {
if (!params.isGuildMessage) return false;
if (params.isThread && params.channelConfig?.autoThread) return false;
// Only skip mention requirement in threads created by the bot (when autoThread is enabled).
const isBotThread = params.isAutoThreadOwnedByBot ?? isDiscordAutoThreadOwnedByBot(params);
if (isBotThread) return false;
return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true;
}
export function isDiscordAutoThreadOwnedByBot(params: {
isThread: boolean;
channelConfig?: DiscordChannelConfigResolved | null;
botId?: string | null;
threadOwnerId?: string | null;
}): boolean {
if (!params.isThread) return false;
if (!params.channelConfig?.autoThread) return false;
const botId = params.botId?.trim();
const threadOwnerId = params.threadOwnerId?.trim();
return Boolean(botId && threadOwnerId && botId === threadOwnerId);
}
export function isDiscordGroupAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";
guildAllowlisted: boolean;

View File

@@ -9,17 +9,24 @@ import { expectInboundContextContract } from "../../../test/helpers/inbound-cont
let capturedCtx: MsgContext | undefined;
vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => {
vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../auto-reply/dispatch.js")>();
const dispatchInboundMessage = vi.fn(async (params: { ctx: MsgContext }) => {
capturedCtx = params.ctx;
return { queuedFinal: false, counts: { tool: 0, block: 0 } };
}),
}));
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
});
return {
...actual,
dispatchInboundMessage,
dispatchInboundMessageWithDispatcher: dispatchInboundMessage,
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage,
};
});
import { processDiscordMessage } from "./message-handler.process.js";
describe("discord processDiscordMessage inbound contract", () => {
it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => {
it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
capturedCtx = undefined;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-"));

View File

@@ -2,7 +2,10 @@ import { ChannelType, MessageType, type User } from "@buape/carbon";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import { recordPendingHistoryEntry, type HistoryEntry } from "../../auto-reply/reply/history.js";
import {
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { recordChannelActivity } from "../../infra/channel-activity.js";
@@ -18,6 +21,7 @@ import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
import { sendMessageDiscord } from "../send.js";
import { resolveControlCommandGate } from "../../channels/command-gating.js";
import { logInboundDrop } from "../../channels/logging.js";
import {
allowListMatches,
isDiscordGroupAllowedByPolicy,
@@ -328,9 +332,12 @@ export async function preflightDiscordMessage(
} satisfies HistoryEntry)
: undefined;
const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
const shouldRequireMention = resolveDiscordShouldRequireMention({
isGuildMessage,
isThread: Boolean(threadChannel),
botId,
threadOwnerId,
channelConfig,
guildInfo,
});
@@ -379,7 +386,12 @@ export async function preflightDiscordMessage(
commandAuthorized = commandGate.commandAuthorized;
if (commandGate.shouldBlock) {
logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`);
logInboundDrop({
log: logVerbose,
channel: "discord",
reason: "control command (unauthorized)",
target: author.id,
});
return null;
}
}
@@ -407,14 +419,12 @@ export async function preflightDiscordMessage(
},
"discord: skipping guild message",
);
if (historyEntry && params.historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: params.guildHistories,
historyKey: message.channelId,
limit: params.historyLimit,
entry: historyEntry,
});
}
recordPendingHistoryEntryIfEnabled({
historyMap: params.guildHistories,
historyKey: message.channelId,
limit: params.historyLimit,
entry: historyEntry ?? null,
});
return null;
}
}

View File

@@ -0,0 +1,123 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const reactMessageDiscord = vi.fn(async () => {});
const removeReactionDiscord = vi.fn(async () => {});
vi.mock("../send.js", () => ({
reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
}));
vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: vi.fn(async () => ({
queuedFinal: false,
counts: { final: 0, tool: 0, block: 0 },
})),
}));
vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({
createReplyDispatcherWithTyping: vi.fn(() => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
})),
}));
import { processDiscordMessage } from "./message-handler.process.js";
async function createBaseContext(overrides: Record<string, unknown> = {}) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-"));
const storePath = path.join(dir, "sessions.json");
return {
cfg: { messages: { ackReaction: "👀" }, session: { store: storePath } },
discordConfig: {},
accountId: "default",
token: "token",
runtime: { log: () => {}, error: () => {} },
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1024,
textLimit: 4000,
replyToMode: "off",
ackReactionScope: "group-mentions",
groupPolicy: "open",
data: { guild: { id: "g1", name: "Guild" } },
client: { rest: {} },
message: {
id: "m1",
channelId: "c1",
timestamp: new Date().toISOString(),
attachments: [],
},
author: {
id: "U1",
username: "alice",
discriminator: "0",
globalName: "Alice",
},
channelInfo: { name: "general" },
channelName: "general",
isGuildMessage: true,
isDirectMessage: false,
isGroupDm: false,
commandAuthorized: true,
baseText: "hi",
messageText: "hi",
wasMentioned: false,
shouldRequireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
shouldBypassMention: false,
threadChannel: null,
threadParentId: undefined,
threadParentName: undefined,
threadParentType: undefined,
threadName: undefined,
displayChannelSlug: "general",
guildInfo: null,
guildSlug: "guild",
channelConfig: null,
baseSessionKey: "agent:main:discord:guild:g1",
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: "agent:main:discord:guild:g1",
mainSessionKey: "agent:main:main",
},
...overrides,
};
}
beforeEach(() => {
reactMessageDiscord.mockClear();
removeReactionDiscord.mockClear();
});
describe("processDiscordMessage ack reactions", () => {
it("skips ack reactions for group-mentions when mentions are not required", async () => {
const ctx = await createBaseContext({
shouldRequireMention: false,
effectiveWasMentioned: false,
});
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord).not.toHaveBeenCalled();
});
it("sends ack reactions for mention-gated guild messages when mentioned", async () => {
const ctx = await createBaseContext({
shouldRequireMention: true,
effectiveWasMentioned: true,
});
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} });
});
});

View File

@@ -1,32 +1,26 @@
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
import {
resolveAckReaction,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
resolveIdentityName,
} from "../../agents/identity.js";
import {
extractShortModelName,
type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js";
removeAckReactionAfterReply,
shouldAckReaction as shouldAckReactionGate,
} from "../../channels/ack-reactions.js";
import { logTypingFailure, logAckFailure } from "../../channels/logging.js";
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
import { createTypingCallbacks } from "../../channels/typing.js";
import {
formatInboundEnvelope,
formatThreadStarterEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
clearHistoryEntriesIfEnabled,
} from "../../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { recordInboundSession } from "../../channels/session.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
@@ -73,6 +67,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
shouldRequireMention,
canDetectMention,
effectiveWasMentioned,
shouldBypassMention,
threadChannel,
threadParentId,
threadParentName,
@@ -95,20 +90,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}
const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return isDirectMessage;
const isGroupChat = isGuildMessage || isGroupDm;
if (ackReactionScope === "group-all") return isGroupChat;
if (ackReactionScope === "group-mentions") {
if (!isGuildMessage) return false;
if (!shouldRequireMention) return false;
if (!canDetectMention) return false;
return effectiveWasMentioned;
}
return false;
};
const shouldAckReaction = () =>
Boolean(
ackReaction &&
shouldAckReactionGate({
scope: ackReactionScope,
isDirect: isDirectMessage,
isGroup: isGuildMessage || isGroupDm,
isMentionableGroup: isGuildMessage,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned,
shouldBypassMention,
}),
);
const ackReactionPromise = shouldAckReaction()
? reactMessageDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest,
@@ -288,27 +283,23 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
});
void recordSessionMetaFromInbound({
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
logVerbose(`discord: failed updating session meta: ${String(err)}`);
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "discord",
to: `user:${author.id}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logVerbose(`discord: failed updating session meta: ${String(err)}`);
},
});
if (isDirectMessage) {
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
channel: "discord",
to: `user:${author.id}`,
accountId: route.accountId,
},
ctx: ctxPayload,
});
}
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
logVerbose(
@@ -320,10 +311,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
? deliverTarget.slice("channel:".length)
: message.channelId;
// Create mutable context for response prefix template interpolation
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
};
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "discord",
@@ -331,8 +319,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
});
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const replyToId = replyReference.use();
@@ -353,10 +341,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
onError: (err, info) => {
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
},
onReplyStart: () => sendTyping({ client, channelId: typingChannelId }),
onReplyStart: createTypingCallbacks({
start: () => sendTyping({ client, channelId: typingChannelId }),
onStartError: (err) => {
logTypingFailure({
log: logVerbose,
channel: "discord",
target: typingChannelId,
error: err,
});
},
}).onReplyStart,
});
const { queuedFinal, counts } = await dispatchReplyFromConfig({
const { queuedFinal, counts } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
@@ -368,20 +366,17 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
? !discordConfig.blockStreaming
: undefined,
onModelSelected: (ctx) => {
// Mutate the object directly instead of reassigning to ensure the closure sees updates
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
prefixContext.onModelSelected(ctx);
},
},
});
markDispatchIdle();
if (!queuedFinal) {
if (isGuildMessage && historyLimit > 0) {
clearHistoryEntries({
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: message.channelId,
limit: historyLimit,
});
}
return;
@@ -392,23 +387,29 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
}
if (removeAckAfterReply && ackReactionPromise && ackReaction) {
const ackReactionValue = ackReaction;
void ackReactionPromise.then((didAck) => {
if (!didAck) return;
removeReactionDiscord(message.channelId, message.id, ackReactionValue, {
removeAckReactionAfterReply({
removeAfterReply: removeAckAfterReply,
ackReactionPromise,
ackReactionValue: ackReaction,
remove: async () => {
await removeReactionDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest,
}).catch((err) => {
logVerbose(
`discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`,
);
});
});
}
if (isGuildMessage && historyLimit > 0) {
clearHistoryEntries({
},
onError: (err) => {
logAckFailure({
log: logVerbose,
channel: "discord",
target: `${message.channelId}/${message.id}`,
error: err,
});
},
});
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: message.channelId,
limit: historyLimit,
});
}
}

View File

@@ -16,6 +16,7 @@ export type DiscordChannelInfo = {
name?: string;
topic?: string;
parentId?: string;
ownerId?: string;
};
type DiscordSnapshotAuthor = {
@@ -69,11 +70,13 @@ export async function resolveDiscordChannelInfo(
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined;
const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined;
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
ownerId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,

View File

@@ -14,6 +14,7 @@ export type DiscordThreadChannel = {
name?: string | null;
parentId?: string | null;
parent?: { id?: string; name?: string };
ownerId?: string | null;
};
export type DiscordThreadStarter = {
@@ -63,6 +64,7 @@ export function resolveDiscordThreadChannel(params: {
name: channelInfo?.name ?? undefined,
parentId: channelInfo?.parentId ?? undefined,
parent: undefined,
ownerId: channelInfo?.ownerId ?? undefined,
};
}

View File

@@ -1,15 +1,9 @@
import type { Client } from "@buape/carbon";
import { logVerbose } from "../../globals.js";
export async function sendTyping(params: { client: Client; channelId: string }) {
try {
const channel = await params.client.fetchChannel(params.channelId);
if (!channel) return;
if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") {
await channel.triggerTyping();
}
} catch (err) {
logVerbose(`discord typing cue failed for channel ${params.channelId}: ${String(err)}`);
const channel = await params.client.fetchChannel(params.channelId);
if (!channel) return;
if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") {
await channel.triggerTyping();
}
}

View File

@@ -92,13 +92,13 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
{
id: Type.Optional(NonEmptyString),
command: NonEmptyString,
cwd: Type.Optional(Type.String()),
host: Type.Optional(Type.String()),
security: Type.Optional(Type.String()),
ask: Type.Optional(Type.String()),
agentId: Type.Optional(Type.String()),
resolvedPath: Type.Optional(Type.String()),
sessionKey: Type.Optional(Type.String()),
cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])),
host: Type.Optional(Type.Union([Type.String(), Type.Null()])),
security: Type.Optional(Type.Union([Type.String(), Type.Null()])),
ask: Type.Optional(Type.Union([Type.String(), Type.Null()])),
agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])),
sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },

View File

@@ -2,30 +2,18 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { ensureAgentWorkspace } from "../../agents/workspace.js";
import { isControlCommandMessage } from "../../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../../auto-reply/commands-registry.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import { buildCommandContext, handleCommands } from "../../auto-reply/reply/commands.js";
import { parseInlineDirectives } from "../../auto-reply/reply/directive-handling.js";
import { defaultGroupActivation } from "../../auto-reply/reply/groups.js";
import { resolveContextTokens } from "../../auto-reply/reply/model-selection.js";
import { resolveElevatedPermissions } from "../../auto-reply/reply/reply-elevated.js";
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import {
normalizeElevatedLevel,
normalizeReasoningLevel,
normalizeThinkLevel,
normalizeVerboseLevel,
} from "../../auto-reply/thinking.js";
extractShortModelName,
type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js";
import type { MsgContext } from "../../auto-reply/templating.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { isAcpSessionKey } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import {
@@ -53,7 +41,144 @@ import {
} from "../session-utils.js";
import { stripEnvelopeFromMessages } from "../chat-sanitize.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers } from "./types.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
type TranscriptAppendResult = {
ok: boolean;
messageId?: string;
message?: Record<string, unknown>;
error?: string;
};
function resolveTranscriptPath(params: {
sessionId: string;
storePath: string | undefined;
sessionFile?: string;
}): string | null {
const { sessionId, storePath, sessionFile } = params;
if (sessionFile) return sessionFile;
if (!storePath) return null;
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
}
function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {
ok: boolean;
error?: string;
} {
if (fs.existsSync(params.transcriptPath)) return { ok: true };
try {
fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true });
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: params.sessionId,
timestamp: new Date().toISOString(),
cwd: process.cwd(),
};
fs.writeFileSync(params.transcriptPath, `${JSON.stringify(header)}\n`, "utf-8");
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
function appendAssistantTranscriptMessage(params: {
message: string;
label?: string;
sessionId: string;
storePath: string | undefined;
sessionFile?: string;
createIfMissing?: boolean;
}): TranscriptAppendResult {
const transcriptPath = resolveTranscriptPath({
sessionId: params.sessionId,
storePath: params.storePath,
sessionFile: params.sessionFile,
});
if (!transcriptPath) {
return { ok: false, error: "transcript path not resolved" };
}
if (!fs.existsSync(transcriptPath)) {
if (!params.createIfMissing) {
return { ok: false, error: "transcript file not found" };
}
const ensured = ensureTranscriptFile({
transcriptPath,
sessionId: params.sessionId,
});
if (!ensured.ok) {
return { ok: false, error: ensured.error ?? "failed to create transcript file" };
}
}
const now = Date.now();
const messageId = randomUUID().slice(0, 8);
const labelPrefix = params.label ? `[${params.label}]\n\n` : "";
const messageBody: Record<string, unknown> = {
role: "assistant",
content: [{ type: "text", text: `${labelPrefix}${params.message}` }],
timestamp: now,
stopReason: "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
const transcriptEntry = {
type: "message",
id: messageId,
timestamp: new Date(now).toISOString(),
message: messageBody,
};
try {
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
return { ok: true, messageId, message: transcriptEntry.message };
}
function nextChatSeq(context: { agentRunSeq: Map<string, number> }, runId: string) {
const next = (context.agentRunSeq.get(runId) ?? 0) + 1;
context.agentRunSeq.set(runId, next);
return next;
}
function broadcastChatFinal(params: {
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
runId: string;
sessionKey: string;
message?: Record<string, unknown>;
}) {
const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId);
const payload = {
runId: params.runId,
sessionKey: params.sessionKey,
seq,
state: "final" as const,
message: params.message,
};
params.context.broadcast("chat", payload);
params.context.nodeSendToSession(params.sessionKey, "chat", payload);
}
function broadcastChatError(params: {
context: Pick<GatewayRequestContext, "broadcast" | "nodeSendToSession" | "agentRunSeq">;
runId: string;
sessionKey: string;
errorMessage?: string;
}) {
const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId);
const payload = {
runId: params.runId,
sessionKey: params.sessionKey,
seq,
state: "error" as const,
errorMessage: params.errorMessage,
};
params.context.broadcast("chat", payload);
params.context.nodeSendToSession(params.sessionKey, "chat", payload);
}
export const chatHandlers: GatewayRequestHandlers = {
"chat.history": async ({ params, respond, context }) => {
@@ -168,7 +293,7 @@ export const chatHandlers: GatewayRequestHandlers = {
runIds: res.aborted ? [runId] : [],
});
},
"chat.send": async ({ params, respond, context }) => {
"chat.send": async ({ params, respond, context, client }) => {
if (!validateChatSendParams(params)) {
respond(
false,
@@ -228,20 +353,13 @@ export const chatHandlers: GatewayRequestHandlers = {
return;
}
}
const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey);
const { cfg, entry } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
});
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
const sessionEntry = mergeSessionEntry(entry, {
sessionId,
updatedAt: now,
});
store[canonicalKey] = sessionEntry;
const clientRunId = p.idempotencyKey;
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
const sendPolicy = resolveSendPolicy({
cfg,
@@ -298,21 +416,11 @@ export const chatHandlers: GatewayRequestHandlers = {
const abortController = new AbortController();
context.chatAbortControllers.set(clientRunId, {
controller: abortController,
sessionId,
sessionId: entry?.sessionId ?? clientRunId,
sessionKey: p.sessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
});
context.addChatRun(clientRunId, {
sessionKey: p.sessionKey,
clientRunId,
});
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[canonicalKey] = sessionEntry;
});
}
const ackPayload = {
runId: clientRunId,
@@ -320,170 +428,116 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
if (isControlCommandMessage(parsedMessage, cfg)) {
try {
const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1";
const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg });
const agentCfg = cfg.agents?.defaults;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const workspace = await ensureAgentWorkspace({
dir: workspaceDir,
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
});
const ctx: MsgContext = {
Body: parsedMessage,
CommandBody: parsedMessage,
BodyForCommands: parsedMessage,
CommandSource: "text",
CommandAuthorized: true,
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: "tui",
From: p.sessionKey,
To: INTERNAL_MESSAGE_CHANNEL,
SessionKey: p.sessionKey,
ChatType: "direct",
};
const command = buildCommandContext({
ctx,
cfg,
agentId,
sessionKey: p.sessionKey,
isGroup: false,
triggerBodyNormalized: normalizeCommandBody(parsedMessage),
commandAuthorized: true,
});
const directives = parseInlineDirectives(parsedMessage);
const { provider, model } = resolveSessionModelRef(cfg, sessionEntry);
const contextTokens = resolveContextTokens({ agentCfg, model });
const resolveDefaultThinkingLevel = async () => {
const configured = agentCfg?.thinkingDefault;
if (configured) return configured;
const catalog = await context.loadGatewayModelCatalog();
return resolveThinkingDefault({ cfg, provider, model, catalog });
};
const resolvedThinkLevel =
normalizeThinkLevel(sessionEntry?.thinkingLevel ?? agentCfg?.thinkingDefault) ??
(await resolveDefaultThinkingLevel());
const resolvedVerboseLevel =
normalizeVerboseLevel(sessionEntry?.verboseLevel ?? agentCfg?.verboseDefault) ?? "off";
const resolvedReasoningLevel =
normalizeReasoningLevel(sessionEntry?.reasoningLevel) ?? "off";
const resolvedElevatedLevel = normalizeElevatedLevel(
sessionEntry?.elevatedLevel ?? agentCfg?.elevatedDefault,
);
const elevated = resolveElevatedPermissions({
cfg,
agentId,
ctx,
provider: INTERNAL_MESSAGE_CHANNEL,
});
const commandResult = await handleCommands({
ctx,
cfg,
command,
agentId,
directives,
elevated,
sessionEntry,
previousSessionEntry: entry,
sessionStore: store,
sessionKey: p.sessionKey,
storePath,
sessionScope: (cfg.session?.scope ?? "per-sender") as "per-sender" | "global",
workspaceDir: workspace.dir,
defaultGroupActivation: () => defaultGroupActivation(true),
resolvedThinkLevel,
resolvedVerboseLevel,
resolvedReasoningLevel,
resolvedElevatedLevel,
resolveDefaultThinkingLevel,
provider,
model,
contextTokens,
isGroup: false,
});
if (!commandResult.shouldContinue) {
const text = commandResult.reply?.text ?? "";
const message = {
role: "assistant",
content: text.trim() ? [{ type: "text", text }] : [],
timestamp: Date.now(),
command: true,
};
const payload = {
const trimmedMessage = parsedMessage.trim();
const injectThinking = Boolean(
p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"),
);
const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage;
const clientInfo = client?.connect?.client;
const ctx: MsgContext = {
Body: parsedMessage,
BodyForAgent: parsedMessage,
BodyForCommands: commandBody,
RawBody: parsedMessage,
CommandBody: commandBody,
SessionKey: p.sessionKey,
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: INTERNAL_MESSAGE_CHANNEL,
OriginatingChannel: INTERNAL_MESSAGE_CHANNEL,
ChatType: "direct",
CommandAuthorized: true,
MessageSid: clientRunId,
SenderId: clientInfo?.id,
SenderName: clientInfo?.displayName,
SenderUsername: clientInfo?.displayName,
};
const agentId = resolveSessionAgentId({
sessionKey: p.sessionKey,
config: cfg,
});
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, agentId),
};
const finalReplyParts: string[] = [];
const dispatcher = createReplyDispatcher({
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
onError: (err) => {
context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);
},
deliver: async (payload, info) => {
if (info.kind !== "final") return;
const text = payload.text?.trim() ?? "";
if (!text) return;
finalReplyParts.push(text);
},
});
let agentRunStarted = false;
void dispatchInboundMessage({
ctx,
cfg,
dispatcher,
replyOptions: {
runId: clientRunId,
abortSignal: abortController.signal,
images: parsedImages.length > 0 ? parsedImages : undefined,
disableBlockStreaming: true,
onAgentRunStart: () => {
agentRunStarted = true;
},
onModelSelected: (ctx) => {
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
},
})
.then(() => {
if (!agentRunStarted) {
const combinedReply = finalReplyParts
.map((part) => part.trim())
.filter(Boolean)
.join("\n\n")
.trim();
let message: Record<string, unknown> | undefined;
if (combinedReply) {
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
p.sessionKey,
);
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
const appended = appendAssistantTranscriptMessage({
message: combinedReply,
sessionId,
storePath: latestStorePath,
sessionFile: latestEntry?.sessionFile,
createIfMissing: true,
});
if (appended.ok) {
message = appended.message;
} else {
context.logGateway.warn(
`webchat transcript append failed: ${appended.error ?? "unknown error"}`,
);
const now = Date.now();
message = {
role: "assistant",
content: [{ type: "text", text: combinedReply }],
timestamp: now,
stopReason: "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
}
}
broadcastChatFinal({
context,
runId: clientRunId,
sessionKey: p.sessionKey,
seq: 0,
state: "final" as const,
message,
};
context.broadcast("chat", payload);
context.nodeSendToSession(p.sessionKey, "chat", payload);
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,
payload: { runId: clientRunId, status: "ok" as const },
});
context.chatAbortControllers.delete(clientRunId);
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
return;
}
} catch (err) {
const payload = {
runId: clientRunId,
sessionKey: p.sessionKey,
seq: 0,
state: "error" as const,
errorMessage: formatForLog(err),
};
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
context.broadcast("chat", payload);
context.nodeSendToSession(p.sessionKey, "chat", payload);
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: false,
payload: {
runId: clientRunId,
status: "error" as const,
summary: String(err),
},
error,
});
context.chatAbortControllers.delete(clientRunId);
context.removeChatRun(clientRunId, clientRunId, p.sessionKey);
return;
}
}
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const envelopedMessage = formatInboundEnvelope({
channel: "WebChat",
from: p.sessionKey,
timestamp: now,
body: parsedMessage,
chatType: "direct",
previousTimestamp: entry?.updatedAt,
envelope: envelopeOptions,
});
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: envelopedMessage,
images: parsedImages.length > 0 ? parsedImages : undefined,
sessionId,
sessionKey: p.sessionKey,
runId: clientRunId,
thinking: p.thinking,
deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: INTERNAL_MESSAGE_CHANNEL,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
context.deps,
)
.then(() => {
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,
@@ -502,6 +556,12 @@ export const chatHandlers: GatewayRequestHandlers = {
},
error,
});
broadcastChatError({
context,
runId: clientRunId,
sessionKey: p.sessionKey,
errorMessage: String(err),
});
})
.finally(() => {
context.chatAbortControllers.delete(clientRunId);

View File

@@ -36,16 +36,16 @@ describe("exec approval handlers", () => {
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
// This documents the TypeBox/AJV behavior that caused the Discord exec bug:
// Type.Optional(Type.String()) does NOT accept null, only string or undefined.
it("rejects request with resolvedPath as null", () => {
// Fixed: null is now accepted (Type.Union([Type.String(), Type.Null()]))
// This matches the calling code in bash-tools.exec.ts which passes null.
it("accepts request with resolvedPath as null", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: null,
};
expect(validateExecApprovalRequestParams(params)).toBe(false);
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
});

View File

@@ -4,8 +4,8 @@ import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
agentCommand,
connectOk,
getReplyFromConfig,
installGatewayTestHooks,
onceMessage,
rpcReq,
@@ -47,7 +47,7 @@ describe("gateway server chat", () => {
async () => {
const tempDirs: string[] = [];
const { server, ws } = await startServerWithClient();
const spy = vi.mocked(agentCommand);
const spy = vi.mocked(getReplyFromConfig);
const resetSpy = () => {
spy.mockReset();
spy.mockResolvedValue(undefined);
@@ -122,8 +122,9 @@ describe("gateway server chat", () => {
let abortInFlight: Promise<unknown> | undefined;
try {
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-1");
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -155,7 +156,7 @@ describe("gateway server chat", () => {
const tick = () => {
if (spy.mock.calls.length > callsBefore) return resolve();
if (Date.now() > deadline)
return reject(new Error("timeout waiting for agentCommand"));
return reject(new Error("timeout waiting for getReplyFromConfig"));
setTimeout(tick, 5);
};
tick();
@@ -177,8 +178,9 @@ describe("gateway server chat", () => {
sessionStoreSaveDelayMs.value = 120;
resetSpy();
try {
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-save-1");
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -215,8 +217,9 @@ describe("gateway server chat", () => {
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
resetSpy();
const callsBeforeStop = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-stop-1");
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -261,7 +264,8 @@ describe("gateway server chat", () => {
const runDone = new Promise<void>((resolve) => {
resolveRun = resolve;
});
spy.mockImplementationOnce(async () => {
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-status-1");
await runDone;
});
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
@@ -294,8 +298,9 @@ describe("gateway server chat", () => {
}
expect(completed).toBe(true);
resetSpy();
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
spy.mockImplementationOnce(async (_ctx, opts) => {
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-all-1");
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -359,9 +364,9 @@ describe("gateway server chat", () => {
const agentStartedP = new Promise<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => {
spy.mockImplementationOnce(async (_ctx, opts) => {
agentStartedResolve?.();
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
const signal = opts?.abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();

View File

@@ -6,8 +6,8 @@ import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import {
agentCommand,
connectOk,
getReplyFromConfig,
installGatewayTestHooks,
onceMessage,
rpcReq,
@@ -71,7 +71,7 @@ describe("gateway server chat", () => {
webchatWs.close();
webchatWs = undefined;
const spy = vi.mocked(agentCommand);
const spy = vi.mocked(getReplyFromConfig);
spy.mockClear();
testState.agentConfig = { timeoutSeconds: 123 };
const callsBeforeTimeout = spy.mock.calls.length;
@@ -83,8 +83,8 @@ describe("gateway server chat", () => {
expect(timeoutRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBeforeTimeout);
const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined;
expect(timeoutCall?.timeout).toBe("123");
const timeoutCall = spy.mock.calls.at(-1)?.[1] as { runId?: string } | undefined;
expect(timeoutCall?.runId).toBe("idem-timeout-1");
testState.agentConfig = undefined;
spy.mockClear();
@@ -97,8 +97,8 @@ describe("gateway server chat", () => {
expect(sessionRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBeforeSession);
const sessionCall = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined;
expect(sessionCall?.sessionKey).toBe("agent:main:subagent:abc");
const sessionCall = spy.mock.calls.at(-1)?.[0] as { SessionKey?: string } | undefined;
expect(sessionCall?.SessionKey).toBe("agent:main:subagent:abc");
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(sendPolicyDir);
@@ -203,10 +203,10 @@ describe("gateway server chat", () => {
expect(imgRes.payload?.runId).toBeDefined();
await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000);
const imgCall = spy.mock.calls.at(-1)?.[0] as
const imgOpts = spy.mock.calls.at(-1)?.[1] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(historyDir);

View File

@@ -166,6 +166,7 @@ const hoisted = vi.hoisted(() => ({
waitCalls: [] as string[],
waitResults: new Map<string, boolean>(),
},
getReplyFromConfig: vi.fn().mockResolvedValue(undefined),
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
}));
@@ -197,6 +198,7 @@ export const testTailnetIPv4 = hoisted.testTailnetIPv4;
export const piSdkMock = hoisted.piSdkMock;
export const cronIsolatedRun = hoisted.cronIsolatedRun;
export const agentCommand = hoisted.agentCommand;
export const getReplyFromConfig = hoisted.getReplyFromConfig;
export const testState = {
agentConfig: undefined as Record<string, unknown> | undefined,
@@ -540,6 +542,9 @@ vi.mock("../channels/web/index.js", async () => {
vi.mock("../commands/agent.js", () => ({
agentCommand,
}));
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig,
}));
vi.mock("../cli/deps.js", async () => {
const actual = await vi.importActual<typeof import("../cli/deps.js")>("../cli/deps.js");
const base = actual.createDefaultDeps();

View File

@@ -1,14 +1,6 @@
import fs from "node:fs/promises";
import {
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
resolveIdentityName,
} from "../../agents/identity.js";
import {
extractShortModelName,
type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js";
import { resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import {
@@ -20,28 +12,26 @@ import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import { logInboundDrop } from "../../channels/logging.js";
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
import { recordInboundSession } from "../../channels/session.js";
import { loadConfig } from "../../config/config.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../../config/group-policy.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { waitForTransportReady } from "../../infra/transport-ready.js";
import { mediaKindFromMime } from "../../media/constants.js";
@@ -52,7 +42,7 @@ import {
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { truncateUtf16Safe } from "../../utils.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { resolveControlCommandGate } from "../../channels/command-gating.js";
import { resolveIMessageAccount } from "../accounts.js";
import { createIMessageRpcClient } from "../client.js";
import { probeIMessage } from "../probe.js";
@@ -383,41 +373,44 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
chatIdentifier,
})
: false;
const commandAuthorized = isGroup
? resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
})
: dmAuthorized;
if (isGroup && hasControlCommand(messageText, cfg) && !commandAuthorized) {
logVerbose(`imessage: drop control command from unauthorized sender ${sender}`);
const hasControlCommandInMessage = hasControlCommand(messageText, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: logVerbose,
channel: "imessage",
reason: "control command (unauthorized)",
target: sender,
});
return;
}
const shouldBypassMention =
isGroup &&
requireMention &&
!mentioned &&
commandAuthorized &&
hasControlCommand(messageText);
isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage;
const effectiveWasMentioned = mentioned || shouldBypassMention;
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
logVerbose(`imessage: skipping group message (no mention)`);
if (historyKey && historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: groupHistories,
historyKey,
limit: historyLimit,
entry: {
sender: senderNormalized,
body: bodyText,
timestamp: createdAt,
messageId: message.id ? String(message.id) : undefined,
},
});
}
recordPendingHistoryEntryIfEnabled({
historyMap: groupHistories,
historyKey: historyKey ?? "",
limit: historyLimit,
entry: historyKey
? {
sender: senderNormalized,
body: bodyText,
timestamp: createdAt,
messageId: message.id ? String(message.id) : undefined,
}
: null,
});
return;
}
@@ -454,7 +447,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
envelope: envelopeOptions,
});
let combinedBody = body;
if (isGroup && historyKey && historyLimit > 0) {
if (isGroup && historyKey) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: groupHistories,
historyKey,
@@ -509,30 +502,25 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
OriginatingTo: imessageTo,
});
void recordSessionMetaFromInbound({
const updateTarget = (isGroup ? chatTarget : undefined) || sender;
await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
updateLastRoute:
!isGroup && updateTarget
? {
sessionKey: route.mainSessionKey,
channel: "imessage",
to: updateTarget,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logVerbose(`imessage: failed updating session meta: ${String(err)}`);
},
});
if (!isGroup) {
const to = (isGroup ? chatTarget : undefined) || sender;
if (to) {
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
channel: "imessage",
to,
accountId: route.accountId,
},
ctx: ctxPayload,
});
}
}
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n");
logVerbose(
@@ -540,14 +528,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
);
}
// Create mutable context for response prefix template interpolation
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
};
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const dispatcher = createReplyDispatcher({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
await deliverReplies({
@@ -565,7 +550,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
},
});
const { queuedFinal } = await dispatchReplyFromConfig({
const { queuedFinal } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
@@ -574,23 +559,21 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
: undefined,
onModelSelected: (ctx) => {
// Mutate the object directly instead of reassigning to ensure the closure sees updates
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
onModelSelected: prefixContext.onModelSelected,
},
});
if (!queuedFinal) {
if (isGroup && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: groupHistories, historyKey });
if (isGroup && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: groupHistories,
historyKey,
limit: historyLimit,
});
}
return;
}
if (isGroup && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: groupHistories, historyKey });
if (isGroup && historyKey) {
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
}
}

View File

@@ -108,8 +108,10 @@ export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
export {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
recordPendingHistoryEntryIfEnabled,
} from "../auto-reply/reply/history.js";
export type { HistoryEntry } from "../auto-reply/reply/history.js";
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
@@ -117,9 +119,23 @@ export {
resolveMentionGating,
resolveMentionGatingWithBypass,
} from "../channels/mention-gating.js";
export type {
AckReactionGateParams,
AckReactionScope,
WhatsAppAckReactionMode,
} from "../channels/ack-reactions.js";
export {
removeAckReactionAfterReply,
shouldAckReaction,
shouldAckReactionForWhatsApp,
} from "../channels/ack-reactions.js";
export { createTypingCallbacks } from "../channels/typing.js";
export { createReplyPrefixContext } from "../channels/reply-prefix.js";
export { logAckFailure, logInboundDrop, logTypingFailure } from "../channels/logging.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export type { NormalizedLocation } from "../channels/location.js";
export { formatLocationText, toLocationContext } from "../channels/location.js";
export { resolveControlCommandGate } from "../channels/command-gating.js";
export {
resolveBlueBubblesGroupRequireMention,
resolveDiscordGroupRequireMention,
@@ -128,6 +144,7 @@ export {
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
} from "../channels/plugins/group-mentions.js";
export { recordInboundSession } from "../channels/session.js";
export {
buildChannelKeyCandidates,
normalizeChannelSlug,

View File

@@ -25,7 +25,9 @@ import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../a
import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js";
import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { recordInboundSession } from "../../channels/session.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
@@ -192,12 +194,17 @@ export function createPluginRuntime(): PluginRuntime {
resolveStorePath,
readSessionUpdatedAt,
recordSessionMetaFromInbound,
recordInboundSession,
updateLastRoute,
},
mentions: {
buildMentionRegexes,
matchesMentionPatterns,
},
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: {
resolveGroupPolicy: resolveChannelGroupPolicy,
resolveRequireMention: resolveChannelGroupRequireMention,

Some files were not shown because too many files have changed in this diff Show More