Compare commits

..
Author SHA1 Message Date
Peter Steinberger 9bba80a4f5 fix: note stable symlinked paths (#1505) (thanks @odysseus0) 2026-01-23 11:52:10 +00:00
George Zhang 05195a390c daemon: prefer symlinked paths over realpath for stable service configs
When installing the LaunchAgent/systemd service, the CLI was using
fs.realpath() to resolve the entry.js path, which converted stable
symlinked paths (e.g. node_modules/clawdbot) into version-specific
paths (e.g. .pnpm/clawdbot@X.Y.Z/...).

This caused the service to break after pnpm updates because the old
versioned path no longer exists, even though the symlink still works.

Now we prefer the original (symlinked) path when it's valid, keeping
service configs stable across package version updates.
2026-01-23 11:48:22 +00:00
319 changed files with 3602 additions and 7602 deletions

No files matched your search

+6 -12
View File
@@ -7,7 +7,6 @@
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
@@ -24,14 +23,13 @@
- 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: 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:
- 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:
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`.
- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
@@ -130,10 +128,6 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lint/format churn:
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
+1 -20
View File
@@ -4,29 +4,10 @@ Docs: https://docs.clawd.bot
## 2026.1.23
### Changes
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Docs: remove the misplaced Google Docs Editor entry from the showcase. (#1547) Thanks @aj47.
### Fixes
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- 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.
- CLI: suppress diagnostic session/run noise during auth probes.
- CLI: hide auth probe timeout warnings from embedded runs.
- CLI: render auth probe results as a table in `clawdbot models status`.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- 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.
- Daemon: prefer symlinked CLI paths to keep service configs stable. (#1505) Thanks @odysseus0.
## 2026.1.22
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601230
versionName = "2026.1.23"
versionCode = 202601210
versionName = "2026.1.21"
}
buildTypes {
@@ -8,14 +8,10 @@ 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 }
}
}
@@ -28,8 +28,6 @@ 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
@@ -51,10 +49,7 @@ 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
@@ -63,7 +58,6 @@ 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) {
@@ -92,8 +86,6 @@ 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)
@@ -112,12 +104,6 @@ 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 ->
@@ -495,27 +481,25 @@ fun SettingsSheet(viewModel: MainViewModel) {
value = wakeWordsText,
onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") },
modifier =
Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (focusState.isFocused) {
wakeWordsHadFocus = true
} else if (wakeWordsHadFocus) {
wakeWordsHadFocus = false
commitWakeWords()
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
commitWakeWords()
focusManager.clearFocus()
},
),
)
}
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
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 {
Text(
if (isConnected) {
@@ -1,7 +1,6 @@
package com.clawdbot.android
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class WakeWordsTest {
@@ -33,18 +32,5 @@ 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)
}
}
+2 -2
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>20260123</string>
<string>20260121</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
@@ -1,10 +1,8 @@
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 {
@@ -14,10 +12,6 @@ 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)
@@ -45,18 +39,17 @@ struct VoiceWakeWordsSettingsView: View {
.onAppear {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
self.commitTriggerWords()
}
}
.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
.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)
}
}
}
@@ -70,7 +63,6 @@ struct VoiceWakeWordsSettingsView: View {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
}
self.commitTriggerWords()
}
private func binding(for index: Int) -> Binding<String> {
@@ -84,15 +76,4 @@ 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)
}
}
}
@@ -6,8 +6,6 @@ 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 }
@@ -32,8 +30,6 @@ 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
}
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>20260123</string>
<string>20260121</string>
</dict>
</plist>
@@ -11,18 +11,6 @@ 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")
}
+4 -4
View File
@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"
@@ -12,8 +12,6 @@ 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"
@@ -84,52 +84,11 @@ enum ExecApprovalDecision: String, Codable, Sendable {
case deny
}
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
var id: UUID
struct ExecAllowlistEntry: Codable, Hashable {
var pattern: String
var lastUsedAt: Double?
var lastUsedCommand: String?
var lastResolvedPath: String?
init(
id: UUID = UUID(),
pattern: String,
lastUsedAt: Double? = nil,
lastUsedCommand: String? = nil,
lastResolvedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastResolvedPath = lastResolvedPath
}
private enum CodingKeys: String, CodingKey {
case id
case pattern
case lastUsedAt
case lastUsedCommand
case lastResolvedPath
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.pattern = try container.decode(String.self, forKey: .pattern)
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.pattern, forKey: .pattern)
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
}
}
struct ExecApprovalsDefaults: Codable {
@@ -336,7 +295,6 @@ enum ExecApprovalsStore {
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in
ExecAllowlistEntry(
id: entry.id,
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
@@ -421,7 +379,6 @@ enum ExecApprovalsStore {
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item }
return ExecAllowlistEntry(
id: item.id,
pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command,
@@ -441,7 +398,6 @@ enum ExecApprovalsStore {
let cleaned = allowlist
.map { item in
ExecAllowlistEntry(
id: item.id,
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand,
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>202601230</string>
<string>202601210</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.model.entries, id: \.id) { entry in
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entry(for: entry.id) ?? entry },
set: { self.model.updateEntry($0, id: entry.id) }),
onRemove: { self.model.removeEntry(id: entry.id) })
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
}
}
}
@@ -373,24 +373,20 @@ final class ExecApprovalsSettingsModel {
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(id: UUID) {
func removeEntry(at index: Int) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func entry(for id: UUID) -> ExecAllowlistEntry? {
self.entries.first(where: { $0.id == id })
}
func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else {
self.skillBins = []
@@ -4,8 +4,6 @@ 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
}
@@ -21,7 +21,6 @@ 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
@@ -32,9 +31,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid }
}
private struct TriggerEntry: Identifiable {
let id: UUID
var value: String
private struct IndexedWord: Identifiable {
let id: Int
let value: String
}
private var voiceWakeBinding: Binding<Bool> {
@@ -106,7 +105,6 @@ struct VoiceWakeSettings: View {
.onAppear {
guard !self.isPreview else { return }
self.startMicObserver()
self.loadTriggerEntries()
}
.onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return }
@@ -124,10 +122,8 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
Task { await self.meter.stop() }
self.micObserver.stop()
self.syncTriggerEntriesToState()
} else {
self.startMicObserver()
self.loadTriggerEntries()
}
}
.onDisappear {
@@ -140,16 +136,11 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
self.micObserver.stop()
Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
}
}
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 indexedWords: [IndexedWord] {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
}
private var triggerTable: some View {
@@ -163,42 +154,29 @@ struct VoiceWakeSettings: View {
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
.disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
}
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()
}
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")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
}
.width(36)
}
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.frame(minHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
@@ -233,12 +211,24 @@ struct VoiceWakeSettings: View {
}
private func addWord() {
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
self.state.swabbleTriggerWords.append("")
}
private func removeWord(id: UUID) {
self.triggerEntries.removeAll { $0.id == id }
self.syncTriggerEntriesToState()
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 toggleTest() {
@@ -648,14 +638,13 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"]
var view = VoiceWakeSettings(state: state, isActive: true)
let 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
@@ -665,9 +654,8 @@ extension VoiceWakeSettings {
_ = view.chimeSection
view.addWord()
if let entryId = view.triggerEntries.first?.id {
view.removeWord(id: entryId)
}
_ = view.binding(for: 0).wrappedValue
view.removeWord(at: 0)
}
}
#endif
@@ -12,18 +12,6 @@ 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")
}
-7
View File
@@ -700,15 +700,8 @@ Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>`
- `--probe-profile <id>` (repeat or comma-separated)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
`--probe` runs live requests (may consume tokens and trigger rate limits).
### `models set <model>`
Set `agents.defaults.model.primary`.
-14
View File
@@ -25,26 +25,12 @@ clawdbot models scan
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
Add `--probe` to run live auth probes against each configured provider profile.
Probes are real requests (may consume tokens and trigger rate limits).
Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
### `models status`
Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>` (probe one provider)
- `--probe-profile <id>` (repeat or comma-separated profile ids)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
## Aliases + fallbacks
```bash
+4 -5
View File
@@ -1,5 +1,5 @@
---
summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)"
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)"
read_when:
- You want to update a source checkout safely
- You need to understand `--update` shorthand behavior
@@ -20,14 +20,14 @@ clawdbot update wizard
clawdbot update --channel beta
clawdbot update --channel dev
clawdbot update --tag beta
clawdbot update --no-restart
clawdbot update --restart
clawdbot update --json
clawdbot --update
```
## Options
- `--no-restart`: skip restarting the Gateway service after a successful update.
- `--restart`: restart the Gateway service after a successful update.
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON.
@@ -52,8 +52,7 @@ Options:
## `update wizard`
Interactive flow to pick an update channel and confirm whether to restart the Gateway
after updating (default is to restart). If you select `dev` without a git checkout, it
offers to create one.
after updating. If you select `dev` without a git checkout, it offers to create one.
## What it does
+1 -25
View File
@@ -25,7 +25,6 @@ stay consistent across channels.
1. **Parse Markdown -> IR**
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
- Offsets are UTF-16 code units so Signal style ranges align with its API.
- Tables are parsed only when a channel opts into table conversion.
2. **Chunk IR (format-first)**
- Chunking happens on the IR text before rendering.
- Inline formatting does not split across chunks; spans are sliced per chunk.
@@ -60,30 +59,7 @@ IR (schematic):
- Slack, Telegram, and Signal outbound adapters render from the IR.
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
their own formatting rules, with Markdown table conversion applied before
chunking when enabled.
## Table handling
Markdown tables are not consistently supported across chat clients. Use
`markdown.tables` to control conversion per channel (and per account).
- `code`: render tables as code blocks (default for most channels).
- `bullets`: convert each row into bullet points (default for Signal + WhatsApp).
- `off`: disable table parsing and conversion; raw table text passes through.
Config keys:
```yaml
channels:
discord:
markdown:
tables: code
accounts:
work:
markdown:
tables: off
```
their own formatting rules.
## Chunking rules
-8
View File
@@ -43,14 +43,6 @@ 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)
+2 -3
View File
@@ -7,7 +7,7 @@ read_when:
# Updating
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart (or use `clawdbot update`, which restarts) → verify.
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
## Recommended: re-run the website installer (upgrade in place)
@@ -81,7 +81,7 @@ Notes:
For **source installs** (git checkout), prefer:
```bash
clawdbot update
clawdbot update --restart
```
It runs a safe-ish update flow:
@@ -89,7 +89,6 @@ It runs a safe-ish update flow:
- Switches to the selected channel (tag or branch).
- Fetches + rebases against the configured upstream (dev channel).
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
- Restarts the gateway by default (use `--no-restart` to skip).
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
+7 -7
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.23 \
APP_VERSION=2026.1.21 \
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.23.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.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.23.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.23 \
APP_VERSION=2026.1.21 \
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.23.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.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.23.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.21.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.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
- Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`.
- 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.
-1
View File
@@ -324,7 +324,6 @@ brew install <formula>
```
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set.
### Can I switch between npm and git installs later?
+7 -1
View File
@@ -327,10 +327,16 @@ Full setup walkthrough (28m) by VelvetShark.
<Card title="OpenRouter Transcription" icon="microphone" href="https://clawdhub.com/obviyus/openrouter-transcribe">
**@obviyus** • `transcription` `multilingual` `skill`
Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on ClawdHub.
</Card>
<Card title="Google Docs Editor" icon="file-word">
**Community** • `docs` `editing` `skill`
Rich-text Google Docs editing skill. Built rapidly with Claude Code.
</Card>
</CardGroup>
## 🏗️ Infrastructure & Deployment
-2
View File
@@ -54,7 +54,6 @@ Example schema:
"autoAllowSkills": true,
"allowlist": [
{
"id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F",
"pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 1737150000000,
"lastUsedCommand": "rg -n TODO",
@@ -97,7 +96,6 @@ Examples:
- `/opt/homebrew/bin/rg`
Each allowlist entry tracks:
- **id** stable UUID used for UI identity (optional)
- **last used** timestamp
- **last used command**
- **last resolved path**
-2
View File
@@ -88,8 +88,6 @@ Session lifecycle:
- `/settings`
- `/exit`
Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands).
## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host.
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
@@ -1,4 +1,3 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -26,7 +25,6 @@ const bluebubblesGroupConfigSchema = z.object({
const bluebubblesAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
serverUrl: z.string().optional(),
password: z.string().optional(),
webhookPath: z.string().optional(),
+36 -50
View File
@@ -2,7 +2,6 @@ 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,
@@ -100,8 +99,6 @@ function createMockRuntime(): PluginRuntime {
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
resolveMarkdownTableMode: vi.fn(() => "code") as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn((text: string) => text) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
@@ -129,7 +126,6 @@ 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"],
},
@@ -137,10 +133,6 @@ 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"],
@@ -228,12 +220,6 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb
return res;
}
const flushAsync = async () => {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
};
describe("BlueBubbles webhook monitor", () => {
let unregister: () => void;
@@ -520,7 +506,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
@@ -568,7 +554,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
@@ -615,7 +601,7 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
// Wait for async processing
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -654,7 +640,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -695,7 +681,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -738,7 +724,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled();
// Should not send pairing reply since created=false
@@ -779,7 +765,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -816,7 +802,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -856,7 +842,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -894,7 +880,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -933,7 +919,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -972,7 +958,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -1013,7 +999,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1054,7 +1040,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -1092,7 +1078,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -1135,7 +1121,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1181,7 +1167,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1227,7 +1213,7 @@ describe("BlueBubbles webhook monitor", () => {
const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
@@ -1251,7 +1237,7 @@ describe("BlueBubbles webhook monitor", () => {
const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1297,7 +1283,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1345,7 +1331,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1398,7 +1384,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Should process even without mention because it's an authorized control command
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -1441,7 +1427,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -1484,7 +1470,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).toHaveBeenCalled();
});
@@ -1525,7 +1511,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
});
@@ -1568,7 +1554,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Should call typing start when reply flow triggers it.
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
@@ -1618,7 +1604,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
@@ -1663,7 +1649,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
@@ -1711,7 +1697,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
@@ -1756,7 +1742,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction added"),
@@ -1796,7 +1782,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction removed"),
@@ -1836,7 +1822,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
});
@@ -1874,7 +1860,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("👍"),
@@ -1915,7 +1901,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1955,7 +1941,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
@@ -2007,7 +1993,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
+56 -81
View File
@@ -1,13 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import {
logAckFailure,
logInboundDrop,
logTypingFailure,
resolveAckReaction,
resolveControlCommandGate,
} from "clawdbot/plugin-sdk";
import { resolveAckReaction } from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js";
@@ -1352,25 +1346,23 @@ async function processMessage(
})
: false;
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
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;
const commandAuthorized = isGroup
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
})
: dmAuthorized;
// Block control commands from unauthorized senders in groups
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
reason: "control command (unauthorized)",
target: message.senderId,
});
if (isGroup && hasControlCmd && !commandAuthorized) {
logVerbose(
core,
runtime,
`bluebubbles: drop control command from unauthorized sender ${message.senderId}`,
);
return;
}
@@ -1529,20 +1521,19 @@ async function processMessage(
core,
runtime,
});
const shouldAckReaction = () =>
Boolean(
ackReactionValue &&
core.channel.reactions.shouldAckReaction({
scope: ackReactionScope,
isDirect: !isGroup,
isGroup,
isMentionableGroup: isGroup,
requireMention: Boolean(requireMention),
canDetectMention,
effectiveWasMentioned,
shouldBypassMention,
}),
);
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 ackMessageId = message.messageId?.trim() || "";
const ackReactionPromise =
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
@@ -1671,15 +1662,9 @@ async function processMessage(
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "bluebubbles",
accountId: account.accountId,
});
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : undefined;
const caption = first ? payload.text : undefined;
first = false;
const result = await sendBlueBubblesMedia({
cfg: config,
@@ -1701,14 +1686,8 @@ async function processMessage(
account.config.textChunkLimit && account.config.textChunkLimit > 0
? account.config.textChunkLimit
: DEFAULT_TEXT_LIMIT;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "bluebubbles",
accountId: account.accountId,
});
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
if (!chunks.length && text) chunks.push(text);
const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit);
if (!chunks.length && payload.text) chunks.push(payload.text);
if (!chunks.length) return;
for (const chunk of chunks) {
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
@@ -1758,27 +1737,29 @@ async function processMessage(
},
});
} finally {
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 (
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 (chatGuidForActions && baseUrl && password && !sentMessage) {
@@ -1787,13 +1768,7 @@ async function processMessage(
cfg: config,
accountId: account.accountId,
}).catch((err) => {
logTypingFailure({
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
action: "stop",
target: chatGuidForActions,
error: err,
});
logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/lobster",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
-2
View File
@@ -1,4 +1,3 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -36,7 +35,6 @@ const matrixRoomSchema = z
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(),
userId: z.string().optional(),
accessToken: z.string().optional(),
+46 -83
View File
@@ -1,12 +1,7 @@
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";
@@ -381,25 +376,21 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
userName: senderName,
})
: false;
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
const commandGate = resolveControlCommandGate({
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
if (isRoom && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "matrix",
reason: "control command (unauthorized)",
target: senderId,
});
if (
isRoom &&
allowTextCommands &&
core.channel.text.hasControlCommand(bodyText, cfg) &&
!commandAuthorized
) {
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
return;
}
const shouldRequireMention = isRoom
@@ -418,8 +409,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
hasControlCommandInMessage;
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
core.channel.text.hasControlCommand(bodyText);
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return;
@@ -496,45 +486,47 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
OriginatingTo: `room:${roomId}`,
});
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) => {
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((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 = () =>
Boolean(
ackReaction &&
core.channel.reactions.shouldAckReaction({
scope: ackScope,
isDirect: isDirectMessage,
isGroup: isRoom,
isMentionableGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned: wasMentioned || shouldBypassMention,
shouldBypassMention,
}),
);
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;
};
if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
@@ -556,38 +548,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}
let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
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: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
await deliverMatrixReplies({
@@ -598,16 +562,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
textLimit,
replyToMode,
threadId: threadTarget,
accountId: route.accountId,
tableMode,
});
didSendReply = true;
},
onError: (err, info) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
onReplyStart: () =>
sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
onIdle: () =>
sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
});
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
@@ -617,7 +581,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected: prefixContext.onModelSelected,
},
});
markDispatchIdle();
@@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js";
import { getMatrixRuntime } from "../../runtime.js";
@@ -12,17 +12,8 @@ export async function deliverMatrixReplies(params: {
textLimit: number;
replyToMode: "off" | "first" | "all";
threadId?: string;
accountId?: string;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const core = getMatrixRuntime();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
channel: "matrix",
accountId: params.accountId,
});
const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message);
@@ -42,8 +33,6 @@ export async function deliverMatrixReplies(params: {
}
const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const mediaList = reply.mediaUrls?.length
? reply.mediaUrls
: reply.mediaUrl
@@ -54,14 +43,13 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) {
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, {
client: params.client,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
accountId: params.accountId,
});
if (shouldIncludeReply(replyToId)) {
hasReplied = true;
@@ -72,14 +60,13 @@ export async function deliverMatrixReplies(params: {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
const caption = first ? (reply.text ?? "") : "";
await sendMessageMatrix(params.roomId, caption, {
client: params.client,
mediaUrl,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
if (shouldIncludeReply(replyToId)) {
hasReplied = true;
@@ -43,8 +43,6 @@ const runtimeStub = {
text: {
resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
+1 -10
View File
@@ -50,18 +50,9 @@ export async function sendMessageMatrix(
try {
const roomId = await resolveMatrixRoomId(client, to);
const cfg = getCore().config.loadConfig();
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: opts.accountId,
});
const convertedMessage = getCore().channel.text.convertMarkdownTables(
trimmedMessage,
tableMode,
);
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId
? buildThreadRelation(threadId, opts.replyToId)
@@ -87,7 +87,6 @@ export type MatrixSendResult = {
export type MatrixSendOpts = {
client?: import("matrix-bot-sdk").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;
threadId?: string | number | null;
timeoutMs?: number;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/mattermost",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Mattermost channel plugin",
"clawdbot": {
@@ -4,7 +4,6 @@ import {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
@@ -12,7 +11,6 @@ const MattermostAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
botToken: z.string().optional(),
+54 -65
View File
@@ -7,15 +7,10 @@ import type {
RuntimeEnv,
} from "clawdbot/plugin-sdk";
import {
createReplyPrefixContext,
createTypingCallbacks,
logInboundDrop,
logTypingFailure,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
recordPendingHistoryEntry,
resolveChannelMediaMaxBytes,
type HistoryEntry,
} from "clawdbot/plugin-sdk";
@@ -35,9 +30,12 @@ import {
} from "./client.js";
import {
createDedupeCache,
extractShortModelName,
formatInboundFromLabel,
rawDataToString,
resolveIdentityName,
resolveThreadSessionKeys,
type ResponsePrefixContext,
} from "./monitor-helpers.js";
import { sendMessageMattermost } from "./send.js";
@@ -309,7 +307,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
};
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
await sendMattermostTyping(client, { channelId, parentId });
try {
await sendMattermostTyping(client, { channelId, parentId });
} catch (err) {
logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
}
};
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
@@ -401,8 +403,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg,
surface: "mattermost",
});
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
const isControlCommand = allowTextCommands && hasControlCommand;
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed({
senderId,
@@ -414,20 +415,19 @@ 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 : commandGate.commandAuthorized;
kind === "dm"
? dmPolicy === "open" || senderAllowedForCommands
: core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
});
if (kind === "dm") {
if (dmPolicy === "disabled") {
@@ -488,13 +488,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
}
if (kind !== "dm" && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "mattermost",
reason: "control command (unauthorized)",
target: senderId,
});
if (kind !== "dm" && isControlCommand && !commandAuthorized) {
logVerboseMessage(
`mattermost: drop control command from unauthorized sender ${senderId}`,
);
return;
}
@@ -537,19 +534,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: "");
const pendingSender = senderName;
const recordPendingHistory = () => {
if (!historyKey || historyLimit <= 0) return;
const trimmed = pendingBody.trim();
recordPendingHistoryEntryIfEnabled({
if (!trimmed) return;
recordPendingHistoryEntry({
historyMap: channelHistories,
historyKey,
limit: historyLimit,
historyKey: historyKey ?? "",
entry: historyKey && trimmed
? {
sender: pendingSender,
body: trimmed,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
messageId: post.id ?? undefined,
}
: null,
entry: {
sender: pendingSender,
body: trimmed,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
messageId: post.id ?? undefined,
},
});
};
@@ -626,7 +623,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
sender: { name: senderName, id: senderId },
});
let combinedBody = body;
if (historyKey) {
if (historyKey && historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: channelHistories,
historyKey,
@@ -710,33 +707,20 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
fallbackLimit: account.textChunkLimit ?? 4000,
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, 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: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
responsePrefixContextProvider: () => prefixContext,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const text = payload.text ?? "";
if (mediaUrls.length === 0) {
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
for (const chunk of chunks.length > 0 ? chunks : [text]) {
@@ -763,7 +747,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
onError: (err, info) => {
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
});
await core.channel.reply.dispatchReplyFromConfig({
@@ -774,12 +758,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected: prefixContext.onModelSelected,
onModelSelected: (ctx) => {
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
},
});
markDispatchIdle();
if (historyKey) {
clearHistoryEntriesIfEnabled({ historyMap: channelHistories, historyKey, limit: historyLimit });
if (historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: channelHistories, historyKey });
}
};
+1 -10
View File
@@ -181,15 +181,6 @@ export async function sendMessageMattermost(
}
}
if (message) {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
message = core.channel.text.convertMarkdownTables(message, tableMode);
}
if (!message && (!fileIds || fileIds.length === 0)) {
if (uploadError) {
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
@@ -214,4 +205,4 @@ export async function sendMessageMattermost(
messageId: post.id ?? "unknown",
channelId,
};
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
@@ -68,10 +68,10 @@ function scopeCandidatesForUrl(url: string): string[] {
host.endsWith("1drv.ms") ||
host.includes("sharepoint");
return looksLikeGraph
? ["https://graph.microsoft.com", "https://api.botframework.com"]
: ["https://api.botframework.com", "https://graph.microsoft.com"];
? ["https://graph.microsoft.com/.default", "https://api.botframework.com/.default"]
: ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
} catch {
return ["https://api.botframework.com", "https://graph.microsoft.com"];
return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
}
}
+1 -1
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");
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
} catch {
return { media: [], messageUrl, tokenError: true };
}
+1 -1
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");
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;
+1 -1
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";
const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
export interface OneDriveUploadResult {
id: string;
+3 -7
View File
@@ -21,8 +21,6 @@ const runtimeStub = {
}
return chunks;
},
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
@@ -36,7 +34,6 @@ describe("msteams messenger", () => {
it("filters silent replies", () => {
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
textChunkLimit: 4000,
tableMode: "code",
});
expect(messages).toEqual([]);
});
@@ -44,7 +41,7 @@ describe("msteams messenger", () => {
it("filters silent reply prefixes", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
{ textChunkLimit: 4000, tableMode: "code" },
{ textChunkLimit: 4000 },
);
expect(messages).toEqual([]);
});
@@ -52,7 +49,7 @@ describe("msteams messenger", () => {
it("splits media into separate messages by default", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000, tableMode: "code" },
{ textChunkLimit: 4000 },
);
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
});
@@ -60,7 +57,7 @@ describe("msteams messenger", () => {
it("supports inline media mode", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
{ textChunkLimit: 4000, mediaMode: "inline" },
);
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
});
@@ -69,7 +66,6 @@ describe("msteams messenger", () => {
const long = "hello ".repeat(200);
const messages = renderReplyPayloadsToMessages([{ text: long }], {
textChunkLimit: 50,
tableMode: "code",
});
expect(messages.length).toBeGreaterThan(1);
});
+1 -12
View File
@@ -1,7 +1,6 @@
import {
isSilentReplyText,
loadWebMedia,
type MarkdownTableMode,
type MSTeamsReplyStyle,
type ReplyPayload,
SILENT_REPLY_TOKEN,
@@ -62,7 +61,6 @@ export type MSTeamsReplyRenderOptions = {
textChunkLimit: number;
chunkText?: boolean;
mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode;
};
/**
@@ -198,19 +196,10 @@ export function renderReplyPayloadsToMessages(
const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false;
const mediaMode = options.mediaMode ?? "split";
const tableMode =
options.tableMode ??
getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
cfg: getMSTeamsRuntime().config.loadConfig(),
channel: "msteams",
});
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
payload.text ?? "",
tableMode,
);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
@@ -1,10 +1,8 @@
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
logInboundDrop,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
recordPendingHistoryEntry,
resolveMentionGating,
formatAllowlistMatchMeta,
type HistoryEntry,
@@ -253,24 +251,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
senderId,
senderName,
});
const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
const commandGate = resolveControlCommandGate({
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
if (commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "msteams",
reason: "control command (unauthorized)",
target: senderId,
});
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
return;
}
@@ -382,17 +371,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
requireMention,
mentioned,
});
recordPendingHistoryEntryIfEnabled({
historyMap: conversationHistories,
historyKey: conversationId,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
});
if (historyLimit > 0) {
recordPendingHistoryEntry({
historyMap: conversationHistories,
historyKey: conversationId,
limit: historyLimit,
entry: {
sender: senderName,
body: rawBody,
timestamp: timestamp?.getTime(),
messageId: activity.id ?? undefined,
},
});
}
return;
}
}
@@ -435,7 +426,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
let combinedBody = body;
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey) {
if (isRoomish && historyKey && historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
@@ -476,13 +467,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload,
});
await core.channel.session.recordInboundSession({
storePath,
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
},
}).catch((err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
});
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
@@ -522,11 +512,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) {
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
}
return;
@@ -535,12 +524,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
if (isRoomish && historyKey) {
clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
if (isRoomish && historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey });
}
} catch (err) {
log.error("dispatch failed", { error: String(err) });
+29 -53
View File
@@ -1,7 +1,4 @@
import {
createReplyPrefixContext,
createTypingCallbacks,
logTypingFailure,
resolveChannelMediaMaxBytes,
type ClawdbotConfig,
type MSTeamsReplyStyle,
@@ -42,39 +39,24 @@ export function createMSTeamsReplyDispatcher(params: {
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
await params.context.sendActivities([{ type: "typing" }]);
try {
await params.context.sendActivities([{ type: "typing" }]);
} catch {
// Typing indicator is best-effort.
}
};
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",
});
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 messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
chunkText: true,
mediaMode: "split",
tableMode,
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
@@ -100,27 +82,21 @@ 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: typingCallbacks.onReplyStart,
});
return {
dispatcher,
replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
markDispatchIdle,
};
},
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,
});
}
+1 -1
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");
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;
+7 -16
View File
@@ -16,7 +16,6 @@ import {
import { extractFilename, extractMessageId } from "./media-helpers.js";
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
export type SendMSTeamsMessageParams = {
@@ -94,21 +93,13 @@ export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl } = params;
const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "msteams",
});
const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables(
text ?? "",
tableMode,
);
const ctx = await resolveMSTeamsSendContext({ cfg, to });
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
log.debug("sending proactive message", {
conversationId,
conversationType,
textLength: messageText.length,
textLength: text.length,
hasMedia: Boolean(mediaUrl),
});
@@ -143,7 +134,7 @@ export async function sendMessageMSTeams(
const { activity, uploadId } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
conversationId,
description: messageText || undefined,
description: text || undefined,
});
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
@@ -181,14 +172,14 @@ export async function sendMessageMSTeams(
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
return sendTextWithMedia(ctx, text, finalMediaUrl);
}
if (isImage && !sharePointSiteId) {
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
return sendTextWithMedia(ctx, text, finalMediaUrl);
}
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
@@ -232,7 +223,7 @@ export async function sendMessageMSTeams(
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
const activity = {
type: "message",
text: messageText || undefined,
text: text || undefined,
attachments: [fileCardAttachment],
};
@@ -273,7 +264,7 @@ export async function sendMessageMSTeams(
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
const activity = {
type: "message",
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
text: text ? `${text}\n\n${fileLink}` : fileLink,
};
const baseRef = buildConversationReference(ref);
@@ -299,7 +290,7 @@ export async function sendMessageMSTeams(
}
// No media: send text only
return sendTextWithMedia(ctx, messageText, undefined);
return sendTextWithMedia(ctx, text, undefined);
}
/**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
@@ -3,7 +3,6 @@ import {
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
import { z } from "zod";
@@ -22,7 +21,6 @@ export const NextcloudTalkAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
baseUrl: z.string().optional(),
botSecret: z.string().optional(),
botSecretFile: z.string().optional(),
+23 -28
View File
@@ -1,9 +1,4 @@
import {
logInboundDrop,
resolveControlCommandGate,
type ClawdbotConfig,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import {
@@ -123,11 +118,7 @@ export async function handleNextcloudTalkInbound(params: {
senderId,
senderName,
}).allowed;
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const commandGate = resolveControlCommandGate({
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{
@@ -136,10 +127,7 @@ export async function handleNextcloudTalkInbound(params: {
allowed: senderAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = commandGate.commandAuthorized;
if (isGroup) {
const groupAllow = resolveNextcloudTalkGroupAllow({
@@ -200,13 +188,15 @@ export async function handleNextcloudTalkInbound(params: {
}
}
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: (message) => runtime.log?.(message),
channel: CHANNEL_ID,
reason: "control command (unauthorized)",
target: senderId,
});
if (
isGroup &&
allowTextCommands &&
core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) &&
commandAuthorized !== true
) {
runtime.log?.(
`nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
);
return;
}
@@ -222,6 +212,10 @@ 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,
@@ -293,14 +287,15 @@ export async function handleNextcloudTalkInbound(params: {
CommandAuthorized: commandAuthorized,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
},
});
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
+1 -11
View File
@@ -71,18 +71,8 @@ export async function sendMessageNextcloudTalk(
throw new Error("Message must be non-empty for Nextcloud Talk sends");
}
const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "nextcloud-talk",
accountId: account.accountId,
});
const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables(
text.trim(),
tableMode,
);
const body: Record<string, unknown> = {
message,
message: text.trim(),
};
if (opts.replyTo) {
body.replyTo = opts.replyTo;
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
+1 -8
View File
@@ -133,20 +133,13 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
deliveryMode: "direct",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId }) => {
const core = getNostrRuntime();
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
const bus = activeBuses.get(aid);
if (!bus) {
throw new Error(`Nostr bus not running for account ${aid}`);
}
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
channel: "nostr",
accountId: aid,
});
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
const normalizedTo = normalizePubkey(to);
await bus.sendDm(normalizedTo, message);
await bus.sendDm(normalizedTo, text);
return { channel: "nostr", to: normalizedTo };
},
},
+1 -4
View File
@@ -1,5 +1,5 @@
import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -63,9 +63,6 @@ export const NostrConfigSchema = z.object({
/** Whether this channel is enabled */
enabled: z.boolean().optional(),
/** Markdown formatting overrides (tables). */
markdown: MarkdownConfigSchema,
/** Private key in hex or nsec bech32 format */
privateKey: z.string().optional(),
+2 -4
View File
@@ -1,11 +1,9 @@
{
"name": "@clawdbot/open-prose",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": {
"extensions": [
"./index.ts"
]
"extensions": ["./index.ts"]
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
-2
View File
@@ -1,4 +1,3 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -6,7 +5,6 @@ const allowFromEntry = z.union([z.string(), z.number()]);
const zaloAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
botToken: z.string().optional(),
tokenFile: z.string().optional(),
webhookUrl: z.string().optional(),
+7 -18
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
@@ -570,19 +570,12 @@ async function processMessageWithPipeline(params: {
OriginatingTo: `zalo:${chatId}`,
});
await core.channel.session.recordInboundSession({
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
},
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalo",
accountId: account.accountId,
}).catch((err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
@@ -598,7 +591,6 @@ async function processMessageWithPipeline(params: {
core,
statusSink,
fetcher,
tableMode,
});
},
onError: (err, info) => {
@@ -616,11 +608,8 @@ async function deliverZaloReply(params: {
core: ZaloCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -631,7 +620,7 @@ async function deliverZaloReply(params: {
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : undefined;
const caption = first ? payload.text : undefined;
first = false;
try {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
@@ -643,8 +632,8 @@ async function deliverZaloReply(params: {
return;
}
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
if (payload.text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
for (const chunk of chunks) {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
-5
View File
@@ -1,10 +1,5 @@
# Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.23",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
-2
View File
@@ -1,4 +1,3 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -11,7 +10,6 @@ const groupConfigSchema = z.object({
const zalouserAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
+7 -16
View File
@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process";
import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
import { sendMessageZalouser } from "./send.js";
import type {
@@ -311,13 +311,12 @@ async function processMessage(
OriginatingTo: `zalouser:${chatId}`,
});
await core.channel.session.recordInboundSession({
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
},
}).catch((err) => {
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
@@ -333,11 +332,6 @@ async function processMessage(
runtime,
core,
statusSink,
tableMode: core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalouser",
accountId: account.accountId,
}),
});
},
onError: (err, info) => {
@@ -357,11 +351,8 @@ async function deliverZalouserReply(params: {
runtime: RuntimeEnv;
core: ZalouserCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -372,7 +363,7 @@ async function deliverZalouserReply(params: {
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : undefined;
const caption = first ? payload.text : undefined;
first = false;
try {
logVerbose(core, runtime, `Sending media to ${chatId}`);
@@ -389,8 +380,8 @@ async function deliverZalouserReply(params: {
return;
}
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
if (payload.text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.23",
"version": "2026.1.22",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
-1
View File
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
vi.mock("./tools/gateway.js", () => ({
-1
View File
@@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("agents_list", () => {
-1
View File
@@ -10,7 +10,6 @@ vi.mock("../media/image-ops.js", () => ({
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
}));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("nodes camera_snap", () => {
@@ -75,7 +75,6 @@ vi.mock("../infra/provider-usage.js", () => ({
formatUsageSummaryLine: () => null,
}));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("session_status tool", () => {
+3 -1
View File
@@ -4,6 +4,9 @@ const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
@@ -20,7 +23,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -22,7 +22,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
});
import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff. Show more