Compare commits

..
Author SHA1 Message Date
Peter Steinberger 865e84d78b docs: clarify heartbeat empty file behavior (#1535) (thanks @JustYannicc) 2026-01-24 03:56:53 +00:00
JustYannicc 7d7f376a16 Merge branch 'main' into feature/heartbeat-optimization 2026-01-23 20:31:39 +00:00
Shiva Prasad fdbaae6a33 macOS: fix trigger word input disappearing when typing and on add (#1506)
Fixed issue where trigger words would disappear when typing or when adding new trigger words. The problem was that `swabbleTriggerWords` changes were triggering `VoiceWakeRuntime.refresh()` which sanitized the array by removing empty strings in real-time.

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

The local state is loaded on view appear and when the view becomes active, ensuring it stays in sync with `AppState`.
2026-01-23 20:08:12 +00:00
justyannicc 7529800dc9 refactor: simplify regex per code review suggestion 2026-01-23 20:01:43 +00:00
7d0a0ae3ba fix(discord): autoThread ack reactions + exec approval null handling (#1511)
* fix(discord): gate autoThread by thread owner

* fix(discord): ack bot-owned autoThreads

* fix(discord): ack mentions in open channels

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

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

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

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

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

---------

Co-authored-by: Wimmie <wimmie@tameson.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-23 20:01:15 +00:00
justyannicc c3b025db26 fix: only treat markdown headers (# followed by space) as comments, not #TODO etc 2026-01-23 19:59:00 +00:00
Peter Steinberger 242add587f fix: quiet auth probe diagnostics 2026-01-23 19:53:01 +00:00
Peter Steinberger 6fba598eaf fix: handle gateway slash command replies in TUI 2026-01-23 19:48:22 +00:00
Peter Steinberger 75a54f0259 docs: note models usage suppression 2026-01-23 19:43:26 +00:00
Peter Steinberger c63144ab14 fix: hide usage errors in status 2026-01-23 19:43:26 +00:00
Peter Steinberger f07c39b265 docs: handle lint/format churn 2026-01-23 19:37:33 +00:00
Peter Steinberger 40181afded feat: add models status auth probes 2026-01-23 19:28:55 +00:00
Peter Steinberger 2f1b9efe9a style: wrap service path helpers 2026-01-23 19:17:57 +00:00
Peter Steinberger ff30cef8a4 fix: expand linux service PATH handling 2026-01-23 19:16:41 +00:00
Robby 3d958d5466 fix(linux): add user bin directories to systemd service PATH for skill installation (#1512)
* fix(linux): add user bin directories to systemd service PATH

Fixes #1503

On Linux, the systemd service PATH was hardcoded to only include system
directories (/usr/local/bin, /usr/bin, /bin), causing binaries installed
via npm global with custom prefix or node version managers to not be found.

This adds common Linux user bin directories to the PATH:
- ~/.local/bin (XDG standard, pip, etc.)
- ~/.npm-global/bin (npm custom prefix)
- ~/bin (user's personal bin)
- Node version manager paths (nvm, fnm, volta, asdf)
- ~/.local/share/pnpm (pnpm global)
- ~/.bun/bin (Bun)

User directories are added before system directories so user-installed
binaries take precedence.

🤖 AI-assisted (Claude Opus 4.5 via Clawdbot)
📋 Testing: Existing unit tests pass (7/7)

* test: add comprehensive tests for Linux user bin directory resolution

- Add dedicated tests for resolveLinuxUserBinDirs() function
- Test path ordering (extraDirs > user dirs > system dirs)
- Test buildMinimalServicePath() with HOME set/unset
- Test platform-specific behavior (Linux vs macOS vs Windows)

Test count: 7 → 20 (+13 tests)

* test: add comprehensive tests for Linux user bin directory handling

- Test Linux user directories included when HOME is set
- Test Linux user directories excluded when HOME is missing
- Test path ordering (extraDirs > user dirs > system dirs)
- Test platform-specific behavior (Linux vs macOS vs Windows)
- Test buildMinimalServicePath() with HOME in env

Covers getMinimalServicePathParts() and buildMinimalServicePath()
for all Linux user bin directory edge cases.

Test count: 7 → 16 (+9 tests)
2026-01-23 19:06:14 +00:00
Peter Steinberger cad7ed1cb8 fix(exec-approvals): stabilize allowlist ids (#1521) 2026-01-23 19:00:45 +00:00
Peter Steinberger 8195497cec fix: surface gateway slash commands in TUI 2026-01-23 18:58:41 +00:00
Peter Steinberger 1af227b619 fix: forward unknown TUI slash commands 2026-01-23 18:41:02 +00:00
Peter Steinberger b77e730657 fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0) 2026-01-23 18:39:25 +00:00
Peter Steinberger 37e5f077b8 test: move gateway server coverage to e2e 2026-01-23 18:34:33 +00:00
Peter Steinberger 0eb7e1864c test: move auto-reply directive coverage to e2e 2026-01-23 18:34:33 +00:00
Peter Steinberger 0d336272f9 test: consolidate auto-reply unit coverage 2026-01-23 18:34:33 +00:00
Peter Steinberger ace6a42ea6 test: dedupe CLI onboard auth cases 2026-01-23 18:34:33 +00:00
Peter Steinberger 6d2a1ce217 test: trim async waits in webhook tests 2026-01-23 18:34:33 +00:00
Peter Steinberger c9d73469c3 test: stub heavy tools in agent tests 2026-01-23 18:34:33 +00:00
Peter Steinberger 29353e2e81 test: speed up default test env 2026-01-23 18:34:33 +00:00
Peter Steinberger fdc50a0feb fix: normalize session lock path 2026-01-23 18:34:33 +00:00
justyannicc 5893e8c759 chore: update HEARTBEAT.md template to be effectively empty by default
Changed instruction text to comment format so new workspaces benefit from
heartbeat optimization immediately. Users still get clear guidance on usage.
2026-01-23 18:10:04 +00:00
justyannicc 744852d313 feat: skip heartbeat API calls when HEARTBEAT.md is effectively empty
- Added isHeartbeatContentEffectivelyEmpty() to detect files with only headers/comments
- Modified runHeartbeatOnce() to check HEARTBEAT.md content before polling the LLM
- Returns early with 'empty-heartbeat-file' reason when no actionable tasks exist
- Preserves existing behavior when file is missing (lets LLM decide)
- Added comprehensive test coverage for empty file detection
- Saves API calls/costs when heartbeat file has no meaningful content
2026-01-23 18:09:31 +00:00
George Zhang a1413a011e feat(telegram): convert markdown tables to bullet points (#1495)
Tables render poorly in Telegram (pipes stripped, whitespace collapses).
This adds a 'tableMode' option to markdownToIR that converts tables to
nested bullet points, which render cleanly on mobile.

- Add tableMode: 'flat' | 'bullets' to MarkdownParseOptions
- Track table state during token rendering
- Render tables as bullet points with first column as row labels
- Apply bold styling to row labels for visual hierarchy
- Enable tableMode: 'bullets' for Telegram formatter

Closes #TBD
2026-01-23 18:00:51 +00:00
George Zhang bfbeea0f20 daemon: prefer symlinked paths over realpath for stable service configs (#1505)
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:52:26 +00:00
Peter Steinberger 2c85b1b409 fix: restart gateway after update by default 2026-01-23 11:50:19 +00:00
236 changed files with 5056 additions and 2400 deletions

No files matched your search

+4
View File
@@ -128,6 +128,10 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. - **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:** 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. - **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. - 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. - **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. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
+39 -2
View File
@@ -2,12 +2,49 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.23 ## 2026.1.23 (Unreleased)
### Changes
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- 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.
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
### Fixes ### Fixes
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) 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`.
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
- CLI: move auth probe errors below the table to reduce wrapping.
- CLI: prevent ANSI color bleed when table cells wrap.
- CLI: explain when auth profiles are excluded by auth.order in probe details.
- CLI: drop the em dash when the banner tagline wraps to a second line.
- CLI: inline auth probe errors in status rows to reduce wrapping.
- Telegram: render markdown in media captions. (#1478)
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
- Daemon: use platform PATH delimiters when building minimal service paths.
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
- 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. - 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) - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Daemon: prefer symlinked CLI paths to keep service configs stable. (#1505) Thanks @odysseus0. - Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
## 2026.1.22 ## 2026.1.22
@@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable {
case deny case deny
} }
struct ExecAllowlistEntry: Codable, Hashable { struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
var id: UUID
var pattern: String var pattern: String
var lastUsedAt: Double? var lastUsedAt: Double?
var lastUsedCommand: String? var lastUsedCommand: String?
var lastResolvedPath: 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 { struct ExecApprovalsDefaults: Codable {
@@ -295,6 +336,7 @@ enum ExecApprovalsStore {
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? [])) let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in .map { entry in
ExecAllowlistEntry( ExecAllowlistEntry(
id: entry.id,
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt, lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand, lastUsedCommand: entry.lastUsedCommand,
@@ -379,6 +421,7 @@ enum ExecApprovalsStore {
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item } guard item.pattern == pattern else { return item }
return ExecAllowlistEntry( return ExecAllowlistEntry(
id: item.id,
pattern: item.pattern, pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000, lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command, lastUsedCommand: command,
@@ -398,6 +441,7 @@ enum ExecApprovalsStore {
let cleaned = allowlist let cleaned = allowlist
.map { item in .map { item in
ExecAllowlistEntry( ExecAllowlistEntry(
id: item.id,
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt, lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand, lastUsedCommand: item.lastUsedCommand,
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in ForEach(self.model.entries, id: \.id) { entry in
ExecAllowlistRow( ExecAllowlistRow(
entry: Binding( entry: Binding(
get: { self.model.entries[index] }, get: { self.model.entry(for: entry.id) ?? entry },
set: { self.model.updateEntry($0, at: index) }), set: { self.model.updateEntry($0, id: entry.id) }),
onRemove: { self.model.removeEntry(at: index) }) onRemove: { self.model.removeEntry(id: entry.id) })
} }
} }
} }
@@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel {
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
} }
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) { func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
guard !self.isDefaultsScope else { return } guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return } guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
self.entries[index] = entry self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
} }
func removeEntry(at index: Int) { func removeEntry(id: UUID) {
guard !self.isDefaultsScope else { return } guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return } guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
self.entries.remove(at: index) self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) 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 { func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else { guard self.autoAllowSkills else {
self.skillBins = [] self.skillBins = []
@@ -21,6 +21,7 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver() @State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>? @State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = [] @State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140 private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240 private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
@@ -31,9 +32,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid } var id: String { self.uid }
} }
private struct IndexedWord: Identifiable { private struct TriggerEntry: Identifiable {
let id: Int let id: UUID
let value: String var value: String
} }
private var voiceWakeBinding: Binding<Bool> { private var voiceWakeBinding: Binding<Bool> {
@@ -105,6 +106,7 @@ struct VoiceWakeSettings: View {
.onAppear { .onAppear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.startMicObserver() self.startMicObserver()
self.loadTriggerEntries()
} }
.onChange(of: self.state.voiceWakeMicID) { _, _ in .onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return } guard !self.isPreview else { return }
@@ -122,8 +124,10 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil self.micRefreshTask = nil
Task { await self.meter.stop() } Task { await self.meter.stop() }
self.micObserver.stop() self.micObserver.stop()
self.syncTriggerEntriesToState()
} else { } else {
self.startMicObserver() self.startMicObserver()
self.loadTriggerEntries()
} }
} }
.onDisappear { .onDisappear {
@@ -136,11 +140,16 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil self.micRefreshTask = nil
self.micObserver.stop() self.micObserver.stop()
Task { await self.meter.stop() } Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
} }
} }
private var indexedWords: [IndexedWord] { private func loadTriggerEntries() {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) } self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
} }
private var triggerTable: some View { private var triggerTable: some View {
@@ -154,29 +163,42 @@ struct VoiceWakeSettings: View {
} label: { } label: {
Label("Add word", systemImage: "plus") Label("Add word", systemImage: "plus")
} }
.disabled(self.state.swabbleTriggerWords .disabled(self.triggerEntries
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
} }
Table(self.indexedWords) { VStack(spacing: 0) {
TableColumn("Word") { row in ForEach(self.$triggerEntries) { $entry in
TextField("Wake word", text: self.binding(for: row.id)) HStack(spacing: 8) {
.textFieldStyle(.roundedBorder) TextField("Wake word", text: $entry.value)
} .textFieldStyle(.roundedBorder)
TableColumn("") { row in .onSubmit {
Button { self.syncTriggerEntriesToState()
self.removeWord(at: row.id) }
} label: {
Image(systemName: "trash") Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
} }
.buttonStyle(.borderless)
.help("Remove trigger word")
} }
.width(36)
} }
.frame(minHeight: 180) .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
@@ -211,24 +233,12 @@ struct VoiceWakeSettings: View {
} }
private func addWord() { private func addWord() {
self.state.swabbleTriggerWords.append("") self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
} }
private func removeWord(at index: Int) { private func removeWord(id: UUID) {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return } self.triggerEntries.removeAll { $0.id == id }
self.state.swabbleTriggerWords.remove(at: index) self.syncTriggerEntriesToState()
}
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() { private func toggleTest() {
@@ -638,13 +648,14 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"] state.swabbleTriggerWords = ["Claude", "Hey"]
let view = VoiceWakeSettings(state: state, isActive: true) var view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")] view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42 view.meterLevel = 0.42
view.meterError = "No input" view.meterError = "No input"
view.testState = .detected("ok") view.testState = .detected("ok")
view.isTesting = true view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body _ = view.body
_ = view.localePicker _ = view.localePicker
@@ -654,8 +665,9 @@ extension VoiceWakeSettings {
_ = view.chimeSection _ = view.chimeSection
view.addWord() view.addWord()
_ = view.binding(for: 0).wrappedValue if let entryId = view.triggerEntries.first?.id {
view.removeWord(at: 0) view.removeWord(id: entryId)
}
} }
} }
#endif #endif
+7
View File
@@ -700,8 +700,15 @@ Options:
- `--json` - `--json`
- `--plain` - `--plain`
- `--check` (exit 1=expired/missing, 2=expiring) - `--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. 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>` ### `models set <model>`
Set `agents.defaults.model.primary`. Set `agents.defaults.model.primary`.
+14
View File
@@ -25,12 +25,26 @@ clawdbot models scan
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview. `clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers. 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: Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias. - `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`). - 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). - 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 ## Aliases + fallbacks
```bash ```bash
+5 -4
View File
@@ -1,5 +1,5 @@
--- ---
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)" summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)"
read_when: read_when:
- You want to update a source checkout safely - You want to update a source checkout safely
- You need to understand `--update` shorthand behavior - You need to understand `--update` shorthand behavior
@@ -20,14 +20,14 @@ clawdbot update wizard
clawdbot update --channel beta clawdbot update --channel beta
clawdbot update --channel dev clawdbot update --channel dev
clawdbot update --tag beta clawdbot update --tag beta
clawdbot update --restart clawdbot update --no-restart
clawdbot update --json clawdbot update --json
clawdbot --update clawdbot --update
``` ```
## Options ## Options
- `--restart`: restart the Gateway service after a successful update. - `--no-restart`: skip restarting the Gateway service after a successful update.
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config). - `--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. - `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON. - `--json`: print machine-readable `UpdateRunResult` JSON.
@@ -52,7 +52,8 @@ Options:
## `update wizard` ## `update wizard`
Interactive flow to pick an update channel and confirm whether to restart the Gateway Interactive flow to pick an update channel and confirm whether to restart the Gateway
after updating. If you select `dev` without a git checkout, it offers to create one. after updating (default is to restart). If you select `dev` without a git checkout, it
offers to create one.
## What it does ## What it does
+25 -1
View File
@@ -25,6 +25,7 @@ stay consistent across channels.
1. **Parse Markdown -> IR** 1. **Parse Markdown -> IR**
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans. - 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. - 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)** 2. **Chunk IR (format-first)**
- Chunking happens on the IR text before rendering. - Chunking happens on the IR text before rendering.
- Inline formatting does not split across chunks; spans are sliced per chunk. - Inline formatting does not split across chunks; spans are sliced per chunk.
@@ -59,7 +60,30 @@ IR (schematic):
- Slack, Telegram, and Signal outbound adapters render from the IR. - Slack, Telegram, and Signal outbound adapters render from the IR.
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or - Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
their own formatting rules. 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
```
## Chunking rules ## Chunking rules
+4
View File
@@ -159,6 +159,10 @@ If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
safe to include every 30 minutes. safe to include every 30 minutes.
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
If the file is missing, the heartbeat still runs and the model decides what to do.
Keep it tiny (short checklist or reminders) to avoid prompt bloat. Keep it tiny (short checklist or reminders) to avoid prompt bloat.
Example `HEARTBEAT.md`: Example `HEARTBEAT.md`:
+3 -2
View File
@@ -7,7 +7,7 @@ read_when:
# Updating # Updating
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify. Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart (or use `clawdbot update`, which restarts) → verify.
## Recommended: re-run the website installer (upgrade in place) ## Recommended: re-run the website installer (upgrade in place)
@@ -81,7 +81,7 @@ Notes:
For **source installs** (git checkout), prefer: For **source installs** (git checkout), prefer:
```bash ```bash
clawdbot update --restart clawdbot update
``` ```
It runs a safe-ish update flow: It runs a safe-ish update flow:
@@ -89,6 +89,7 @@ It runs a safe-ish update flow:
- Switches to the selected channel (tag or branch). - Switches to the selected channel (tag or branch).
- Fetches + rebases against the configured upstream (dev channel). - Fetches + rebases against the configured upstream (dev channel).
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`. - 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. 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.
+2 -1
View File
@@ -5,4 +5,5 @@ read_when:
--- ---
# HEARTBEAT.md # HEARTBEAT.md
Keep this file empty unless you want a tiny checklist. Keep it small. # Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
+2
View File
@@ -182,6 +182,8 @@ By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.` `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
Set `agents.defaults.heartbeat.every: "0m"` to disable. Set `agents.defaults.heartbeat.every: "0m"` to disable.
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
- If the file is missing, the heartbeat still runs and the model decides what to do.
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
- Heartbeats run full agent turns — shorter intervals burn more tokens. - Heartbeats run full agent turns — shorter intervals burn more tokens.
+5
View File
@@ -324,6 +324,7 @@ 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. 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? ### Can I switch between npm and git installs later?
@@ -970,6 +971,10 @@ Heartbeats run every **30m** by default. Tune or disable them:
} }
``` ```
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
If the file is missing, the heartbeat still runs and the model decides what to do.
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat). Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
### Do I need to add a “bot account” to a WhatsApp group? ### Do I need to add a “bot account” to a WhatsApp group?
+2
View File
@@ -54,6 +54,7 @@ Example schema:
"autoAllowSkills": true, "autoAllowSkills": true,
"allowlist": [ "allowlist": [
{ {
"id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F",
"pattern": "~/Projects/**/bin/rg", "pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 1737150000000, "lastUsedAt": 1737150000000,
"lastUsedCommand": "rg -n TODO", "lastUsedCommand": "rg -n TODO",
@@ -96,6 +97,7 @@ Examples:
- `/opt/homebrew/bin/rg` - `/opt/homebrew/bin/rg`
Each allowlist entry tracks: Each allowlist entry tracks:
- **id** stable UUID used for UI identity (optional)
- **last used** timestamp - **last used** timestamp
- **last used command** - **last used command**
- **last resolved path** - **last resolved path**
+2
View File
@@ -88,6 +88,8 @@ Session lifecycle:
- `/settings` - `/settings`
- `/exit` - `/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 ## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host. - 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. - The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -25,6 +26,7 @@ const bluebubblesGroupConfigSchema = z.object({
const bluebubblesAccountSchema = z.object({ const bluebubblesAccountSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
serverUrl: z.string().optional(), serverUrl: z.string().optional(),
password: z.string().optional(), password: z.string().optional(),
webhookPath: z.string().optional(), webhookPath: z.string().optional(),
+44 -36
View File
@@ -99,6 +99,8 @@ function createMockRuntime(): PluginRuntime {
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"], chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"], resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"], 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: { reply: {
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
@@ -220,6 +222,12 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb
return res; return res;
} }
const flushAsync = async () => {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
};
describe("BlueBubbles webhook monitor", () => { describe("BlueBubbles webhook monitor", () => {
let unregister: () => void; let unregister: () => void;
@@ -506,7 +514,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(resolveChatGuidForTarget).toHaveBeenCalledWith( expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -554,7 +562,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith( expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
@@ -601,7 +609,7 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
// Wait for async processing // Wait for async processing
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -640,7 +648,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -681,7 +689,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockUpsertPairingRequest).toHaveBeenCalled(); expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -724,7 +732,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockUpsertPairingRequest).toHaveBeenCalled(); expect(mockUpsertPairingRequest).toHaveBeenCalled();
// Should not send pairing reply since created=false // Should not send pairing reply since created=false
@@ -765,7 +773,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
}); });
@@ -802,7 +810,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -842,7 +850,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
}); });
@@ -880,7 +888,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -919,7 +927,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -958,7 +966,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
}); });
@@ -999,7 +1007,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1040,7 +1048,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -1078,7 +1086,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
}); });
@@ -1121,7 +1129,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1167,7 +1175,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1213,7 +1221,7 @@ describe("BlueBubbles webhook monitor", () => {
const originalRes = createMockResponse(); const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes); await handleBlueBubblesWebhookRequest(originalReq, originalRes);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
// Only assert the reply message behavior below. // Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
@@ -1237,7 +1245,7 @@ describe("BlueBubbles webhook monitor", () => {
const replyRes = createMockResponse(); const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes); await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1283,7 +1291,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1331,7 +1339,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(sendBlueBubblesReaction).toHaveBeenCalledWith( expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -1384,7 +1392,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
// Should process even without mention because it's an authorized control command // Should process even without mention because it's an authorized control command
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -1427,7 +1435,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
@@ -1470,7 +1478,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(markBlueBubblesChatRead).toHaveBeenCalled(); expect(markBlueBubblesChatRead).toHaveBeenCalled();
}); });
@@ -1511,7 +1519,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(markBlueBubblesChatRead).not.toHaveBeenCalled(); expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
}); });
@@ -1554,7 +1562,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
// Should call typing start when reply flow triggers it. // Should call typing start when reply flow triggers it.
expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
@@ -1604,7 +1612,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
@@ -1649,7 +1657,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
@@ -1697,7 +1705,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2") // Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
@@ -1742,7 +1750,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction added"), expect.stringContaining("reaction added"),
@@ -1782,7 +1790,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction removed"), expect.stringContaining("reaction removed"),
@@ -1822,7 +1830,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
}); });
@@ -1860,7 +1868,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("👍"), expect.stringContaining("👍"),
@@ -1901,7 +1909,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1941,7 +1949,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
// The short ID "1" should resolve back to the full UUID // The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345"); expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
@@ -1993,7 +2001,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse(); const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res); await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50)); await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
}); });
+15 -3
View File
@@ -1662,9 +1662,15 @@ async function processMessage(
? [payload.mediaUrl] ? [payload.mediaUrl]
: []; : [];
if (mediaList.length > 0) { 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; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined; const caption = first ? text : undefined;
first = false; first = false;
const result = await sendBlueBubblesMedia({ const result = await sendBlueBubblesMedia({
cfg: config, cfg: config,
@@ -1686,8 +1692,14 @@ async function processMessage(
account.config.textChunkLimit && account.config.textChunkLimit > 0 account.config.textChunkLimit && account.config.textChunkLimit > 0
? account.config.textChunkLimit ? account.config.textChunkLimit
: DEFAULT_TEXT_LIMIT; : DEFAULT_TEXT_LIMIT;
const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit); const tableMode = core.channel.text.resolveMarkdownTableMode({
if (!chunks.length && payload.text) chunks.push(payload.text); 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);
if (!chunks.length) return; if (!chunks.length) return;
for (const chunk of chunks) { for (const chunk of chunks) {
const result = await sendMessageBlueBubbles(outboundTarget, chunk, { const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
+2
View File
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -35,6 +36,7 @@ const matrixRoomSchema = z
export const MatrixConfigSchema = z.object({ export const MatrixConfigSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(), homeserver: z.string().optional(),
userId: z.string().optional(), userId: z.string().optional(),
accessToken: z.string().optional(), accessToken: z.string().optional(),
@@ -548,6 +548,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
} }
let didSendReply = false; let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: route.accountId,
});
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({ core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
@@ -562,6 +567,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
textLimit, textLimit,
replyToMode, replyToMode,
threadId: threadTarget, threadId: threadTarget,
accountId: route.accountId,
tableMode,
}); });
didSendReply = true; didSendReply = true;
}, },
@@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-bot-sdk"; import type { MatrixClient } from "matrix-bot-sdk";
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js"; import { sendMessageMatrix } from "../send.js";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
@@ -12,8 +12,17 @@ export async function deliverMatrixReplies(params: {
textLimit: number; textLimit: number;
replyToMode: "off" | "first" | "all"; replyToMode: "off" | "first" | "all";
threadId?: string; threadId?: string;
accountId?: string;
tableMode?: MarkdownTableMode;
}): Promise<void> { }): Promise<void> {
const core = getMatrixRuntime(); const core = getMatrixRuntime();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
channel: "matrix",
accountId: params.accountId,
});
const logVerbose = (message: string) => { const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) { if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message); params.runtime.log?.(message);
@@ -33,6 +42,8 @@ export async function deliverMatrixReplies(params: {
} }
const replyToIdRaw = reply.replyToId?.trim(); const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; 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 const mediaList = reply.mediaUrls?.length
? reply.mediaUrls ? reply.mediaUrls
: reply.mediaUrl : reply.mediaUrl
@@ -43,13 +54,14 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied); Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) { if (mediaList.length === 0) {
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) { for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (!trimmed) continue; if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, { await sendMessageMatrix(params.roomId, trimmed, {
client: params.client, client: params.client,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId, threadId: params.threadId,
accountId: params.accountId,
}); });
if (shouldIncludeReply(replyToId)) { if (shouldIncludeReply(replyToId)) {
hasReplied = true; hasReplied = true;
@@ -60,13 +72,14 @@ export async function deliverMatrixReplies(params: {
let first = true; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? (reply.text ?? "") : ""; const caption = first ? text : "";
await sendMessageMatrix(params.roomId, caption, { await sendMessageMatrix(params.roomId, caption, {
client: params.client, client: params.client,
mediaUrl, mediaUrl,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId, threadId: params.threadId,
audioAsVoice: reply.audioAsVoice, audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
}); });
if (shouldIncludeReply(replyToId)) { if (shouldIncludeReply(replyToId)) {
hasReplied = true; hasReplied = true;
@@ -43,6 +43,8 @@ const runtimeStub = {
text: { text: {
resolveTextChunkLimit: () => 4000, resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []), chunkMarkdownText: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
}, },
}, },
} as unknown as PluginRuntime; } as unknown as PluginRuntime;
+10 -1
View File
@@ -50,9 +50,18 @@ export async function sendMessageMatrix(
try { try {
const roomId = await resolveMatrixRoomId(client, to); const roomId = await resolveMatrixRoomId(client, to);
const cfg = getCore().config.loadConfig(); 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 textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit); const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId); const threadId = normalizeThreadId(opts.threadId);
const relation = threadId const relation = threadId
? buildThreadRelation(threadId, opts.replyToId) ? buildThreadRelation(threadId, opts.replyToId)
@@ -87,6 +87,7 @@ export type MatrixSendResult = {
export type MatrixSendOpts = { export type MatrixSendOpts = {
client?: import("matrix-bot-sdk").MatrixClient; client?: import("matrix-bot-sdk").MatrixClient;
mediaUrl?: string; mediaUrl?: string;
accountId?: string;
replyToId?: string; replyToId?: string;
threadId?: string | number | null; threadId?: string | number | null;
timeoutMs?: number; timeoutMs?: number;
@@ -4,6 +4,7 @@ import {
BlockStreamingCoalesceSchema, BlockStreamingCoalesceSchema,
DmPolicySchema, DmPolicySchema,
GroupPolicySchema, GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom, requireOpenAllowFrom,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
@@ -11,6 +12,7 @@ const MattermostAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
configWrites: z.boolean().optional(), configWrites: z.boolean().optional(),
botToken: z.string().optional(), botToken: z.string().optional(),
@@ -707,6 +707,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, { const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
fallbackLimit: account.textChunkLimit ?? 4000, fallbackLimit: account.textChunkLimit ?? 4000,
}); });
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
let prefixContext: ResponsePrefixContext = { let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId), identityName: resolveIdentityName(cfg, route.agentId),
@@ -720,7 +725,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => { deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? ""; const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
if (mediaUrls.length === 0) { if (mediaUrls.length === 0) {
const chunks = core.channel.text.chunkMarkdownText(text, textLimit); const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
for (const chunk of chunks.length > 0 ? chunks : [text]) { for (const chunk of chunks.length > 0 ? chunks : [text]) {
+10 -1
View File
@@ -181,6 +181,15 @@ 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 (!message && (!fileIds || fileIds.length === 0)) {
if (uploadError) { if (uploadError) {
throw new Error(`Mattermost media upload failed: ${uploadError.message}`); throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
@@ -205,4 +214,4 @@ export async function sendMessageMattermost(
messageId: post.id ?? "unknown", messageId: post.id ?? "unknown",
channelId, channelId,
}; };
} }
+7 -3
View File
@@ -21,6 +21,8 @@ const runtimeStub = {
} }
return chunks; return chunks;
}, },
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
}, },
}, },
} as unknown as PluginRuntime; } as unknown as PluginRuntime;
@@ -34,6 +36,7 @@ describe("msteams messenger", () => {
it("filters silent replies", () => { it("filters silent replies", () => {
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], { const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
textChunkLimit: 4000, textChunkLimit: 4000,
tableMode: "code",
}); });
expect(messages).toEqual([]); expect(messages).toEqual([]);
}); });
@@ -41,7 +44,7 @@ describe("msteams messenger", () => {
it("filters silent reply prefixes", () => { it("filters silent reply prefixes", () => {
const messages = renderReplyPayloadsToMessages( const messages = renderReplyPayloadsToMessages(
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }], [{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
{ textChunkLimit: 4000 }, { textChunkLimit: 4000, tableMode: "code" },
); );
expect(messages).toEqual([]); expect(messages).toEqual([]);
}); });
@@ -49,7 +52,7 @@ describe("msteams messenger", () => {
it("splits media into separate messages by default", () => { it("splits media into separate messages by default", () => {
const messages = renderReplyPayloadsToMessages( const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }], [{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000 }, { textChunkLimit: 4000, tableMode: "code" },
); );
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]); expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
}); });
@@ -57,7 +60,7 @@ describe("msteams messenger", () => {
it("supports inline media mode", () => { it("supports inline media mode", () => {
const messages = renderReplyPayloadsToMessages( const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }], [{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000, mediaMode: "inline" }, { textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
); );
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]); expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
}); });
@@ -66,6 +69,7 @@ describe("msteams messenger", () => {
const long = "hello ".repeat(200); const long = "hello ".repeat(200);
const messages = renderReplyPayloadsToMessages([{ text: long }], { const messages = renderReplyPayloadsToMessages([{ text: long }], {
textChunkLimit: 50, textChunkLimit: 50,
tableMode: "code",
}); });
expect(messages.length).toBeGreaterThan(1); expect(messages.length).toBeGreaterThan(1);
}); });
+12 -1
View File
@@ -1,6 +1,7 @@
import { import {
isSilentReplyText, isSilentReplyText,
loadWebMedia, loadWebMedia,
type MarkdownTableMode,
type MSTeamsReplyStyle, type MSTeamsReplyStyle,
type ReplyPayload, type ReplyPayload,
SILENT_REPLY_TOKEN, SILENT_REPLY_TOKEN,
@@ -61,6 +62,7 @@ export type MSTeamsReplyRenderOptions = {
textChunkLimit: number; textChunkLimit: number;
chunkText?: boolean; chunkText?: boolean;
mediaMode?: "split" | "inline"; mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode;
}; };
/** /**
@@ -196,10 +198,19 @@ export function renderReplyPayloadsToMessages(
const chunkLimit = Math.min(options.textChunkLimit, 4000); const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false; const chunkText = options.chunkText !== false;
const mediaMode = options.mediaMode ?? "split"; const mediaMode = options.mediaMode ?? "split";
const tableMode =
options.tableMode ??
getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
cfg: getMSTeamsRuntime().config.loadConfig(),
channel: "msteams",
});
for (const payload of replies) { for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? ""; const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
payload.text ?? "",
tableMode,
);
if (!text && mediaList.length === 0) continue; if (!text && mediaList.length === 0) continue;
@@ -53,10 +53,15 @@ export function createMSTeamsReplyDispatcher(params: {
).responsePrefix, ).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => { deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
});
const messages = renderReplyPayloadsToMessages([payload], { const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit, textChunkLimit: params.textLimit,
chunkText: true, chunkText: true,
mediaMode: "split", mediaMode: "split",
tableMode,
}); });
const mediaMaxBytes = resolveChannelMediaMaxBytes({ const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg, cfg: params.cfg,
+16 -7
View File
@@ -16,6 +16,7 @@ import {
import { extractFilename, extractMessageId } from "./media-helpers.js"; import { extractFilename, extractMessageId } from "./media-helpers.js";
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js"; import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js"; import { buildMSTeamsPollCard } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js"; import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
export type SendMSTeamsMessageParams = { export type SendMSTeamsMessageParams = {
@@ -93,13 +94,21 @@ export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams, params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> { ): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl } = params; 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 ctx = await resolveMSTeamsSendContext({ cfg, to });
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx; const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
log.debug("sending proactive message", { log.debug("sending proactive message", {
conversationId, conversationId,
conversationType, conversationType,
textLength: text.length, textLength: messageText.length,
hasMedia: Boolean(mediaUrl), hasMedia: Boolean(mediaUrl),
}); });
@@ -134,7 +143,7 @@ export async function sendMessageMSTeams(
const { activity, uploadId } = prepareFileConsentActivity({ const { activity, uploadId } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType }, media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
conversationId, conversationId,
description: text || undefined, description: messageText || undefined,
}); });
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length }); log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
@@ -172,14 +181,14 @@ export async function sendMessageMSTeams(
const base64 = media.buffer.toString("base64"); const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`; const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, text, finalMediaUrl); return sendTextWithMedia(ctx, messageText, finalMediaUrl);
} }
if (isImage && !sharePointSiteId) { if (isImage && !sharePointSiteId) {
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures) // Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
const base64 = media.buffer.toString("base64"); const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`; const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, text, finalMediaUrl); return sendTextWithMedia(ctx, messageText, finalMediaUrl);
} }
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive // Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
@@ -223,7 +232,7 @@ export async function sendMessageMSTeams(
const fileCardAttachment = buildTeamsFileInfoCard(driveItem); const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
const activity = { const activity = {
type: "message", type: "message",
text: text || undefined, text: messageText || undefined,
attachments: [fileCardAttachment], attachments: [fileCardAttachment],
}; };
@@ -264,7 +273,7 @@ export async function sendMessageMSTeams(
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`; const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
const activity = { const activity = {
type: "message", type: "message",
text: text ? `${text}\n\n${fileLink}` : fileLink, text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
}; };
const baseRef = buildConversationReference(ref); const baseRef = buildConversationReference(ref);
@@ -290,7 +299,7 @@ export async function sendMessageMSTeams(
} }
// No media: send text only // No media: send text only
return sendTextWithMedia(ctx, text, undefined); return sendTextWithMedia(ctx, messageText, undefined);
} }
/** /**
@@ -3,6 +3,7 @@ import {
DmConfigSchema, DmConfigSchema,
DmPolicySchema, DmPolicySchema,
GroupPolicySchema, GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom, requireOpenAllowFrom,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
@@ -21,6 +22,7 @@ export const NextcloudTalkAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
baseUrl: z.string().optional(), baseUrl: z.string().optional(),
botSecret: z.string().optional(), botSecret: z.string().optional(),
botSecretFile: z.string().optional(), botSecretFile: z.string().optional(),
+11 -1
View File
@@ -71,8 +71,18 @@ export async function sendMessageNextcloudTalk(
throw new Error("Message must be non-empty for Nextcloud Talk sends"); 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> = { const body: Record<string, unknown> = {
message: text.trim(), message,
}; };
if (opts.replyTo) { if (opts.replyTo) {
body.replyTo = opts.replyTo; body.replyTo = opts.replyTo;
+8 -1
View File
@@ -133,13 +133,20 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
deliveryMode: "direct", deliveryMode: "direct",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, accountId }) => { sendText: async ({ to, text, accountId }) => {
const core = getNostrRuntime();
const aid = accountId ?? DEFAULT_ACCOUNT_ID; const aid = accountId ?? DEFAULT_ACCOUNT_ID;
const bus = activeBuses.get(aid); const bus = activeBuses.get(aid);
if (!bus) { if (!bus) {
throw new Error(`Nostr bus not running for account ${aid}`); 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); const normalizedTo = normalizePubkey(to);
await bus.sendDm(normalizedTo, text); await bus.sendDm(normalizedTo, message);
return { channel: "nostr", to: normalizedTo }; return { channel: "nostr", to: normalizedTo };
}, },
}, },
+4 -1
View File
@@ -1,5 +1,5 @@
import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -63,6 +63,9 @@ export const NostrConfigSchema = z.object({
/** Whether this channel is enabled */ /** Whether this channel is enabled */
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
/** Markdown formatting overrides (tables). */
markdown: MarkdownConfigSchema,
/** Private key in hex or nsec bech32 format */ /** Private key in hex or nsec bech32 format */
privateKey: z.string().optional(), privateKey: z.string().optional(),
+2
View File
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -5,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
const zaloAccountSchema = z.object({ const zaloAccountSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
botToken: z.string().optional(), botToken: z.string().optional(),
tokenFile: z.string().optional(), tokenFile: z.string().optional(),
webhookUrl: z.string().optional(), webhookUrl: z.string().optional(),
+14 -4
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js"; import type { ResolvedZaloAccount } from "./accounts.js";
import { import {
@@ -578,6 +578,12 @@ async function processMessageWithPipeline(params: {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
}); });
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalo",
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
cfg: config, cfg: config,
@@ -591,6 +597,7 @@ async function processMessageWithPipeline(params: {
core, core,
statusSink, statusSink,
fetcher, fetcher,
tableMode,
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
@@ -608,8 +615,11 @@ async function deliverZaloReply(params: {
core: ZaloCoreRuntime; core: ZaloCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch; fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode;
}): Promise<void> { }): Promise<void> {
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params; 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 const mediaList = payload.mediaUrls?.length
? payload.mediaUrls ? payload.mediaUrls
@@ -620,7 +630,7 @@ async function deliverZaloReply(params: {
if (mediaList.length > 0) { if (mediaList.length > 0) {
let first = true; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined; const caption = first ? text : undefined;
first = false; first = false;
try { try {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
@@ -632,8 +642,8 @@ async function deliverZaloReply(params: {
return; return;
} }
if (payload.text) { if (text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT); const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
for (const chunk of chunks) { for (const chunk of chunks) {
try { try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
+2
View File
@@ -1,3 +1,4 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod"; import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]); const allowFromEntry = z.union([z.string(), z.number()]);
@@ -10,6 +11,7 @@ const groupConfigSchema = z.object({
const zalouserAccountSchema = z.object({ const zalouserAccountSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(), profile: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(), allowFrom: z.array(allowFromEntry).optional(),
+12 -4
View File
@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk";
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk"; import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
import { sendMessageZalouser } from "./send.js"; import { sendMessageZalouser } from "./send.js";
import type { import type {
@@ -332,6 +332,11 @@ async function processMessage(
runtime, runtime,
core, core,
statusSink, statusSink,
tableMode: core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalouser",
accountId: account.accountId,
}),
}); });
}, },
onError: (err, info) => { onError: (err, info) => {
@@ -351,8 +356,11 @@ async function deliverZalouserReply(params: {
runtime: RuntimeEnv; runtime: RuntimeEnv;
core: ZalouserCoreRuntime; core: ZalouserCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
tableMode?: MarkdownTableMode;
}): Promise<void> { }): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params; 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 const mediaList = payload.mediaUrls?.length
? payload.mediaUrls ? payload.mediaUrls
@@ -363,7 +371,7 @@ async function deliverZalouserReply(params: {
if (mediaList.length > 0) { if (mediaList.length > 0) {
let first = true; let first = true;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined; const caption = first ? text : undefined;
first = false; first = false;
try { try {
logVerbose(core, runtime, `Sending media to ${chatId}`); logVerbose(core, runtime, `Sending media to ${chatId}`);
@@ -380,8 +388,8 @@ async function deliverZalouserReply(params: {
return; return;
} }
if (payload.text) { if (text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT); const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`); logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) { for (const chunk of chunks) {
try { try {
+2
View File
@@ -376,6 +376,8 @@ importers:
specifier: ^4.3.5 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
extensions/open-prose: {}
extensions/signal: {} extensions/signal: {}
extensions/slack: {} extensions/slack: {}
+1
View File
@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
vi.mock("./tools/gateway.js", () => ({ vi.mock("./tools/gateway.js", () => ({
+1
View File
@@ -16,6 +16,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
}; };
}); });
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
describe("agents_list", () => { describe("agents_list", () => {
+1
View File
@@ -10,6 +10,7 @@ vi.mock("../media/image-ops.js", () => ({
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")), resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
})); }));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
describe("nodes camera_snap", () => { describe("nodes camera_snap", () => {
@@ -75,6 +75,7 @@ vi.mock("../infra/provider-usage.js", () => ({
formatUsageSummaryLine: () => null, formatUsageSummaryLine: () => null,
})); }));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
describe("session_status tool", () => { describe("session_status tool", () => {
+1 -3
View File
@@ -4,9 +4,6 @@ const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts), callGateway: (opts: unknown) => callGatewayMock(opts),
})); }));
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
}));
vi.mock("../config/config.js", async (importOriginal) => { vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>(); const actual = await importOriginal<typeof import("../config/config.js")>();
@@ -23,6 +20,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
}; };
}); });
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => { const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
@@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
}; };
}); });
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
}; };
}); });
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
}; };
}); });
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -22,6 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
}); });
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
}; };
}); });
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -22,6 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
}); });
import { emitAgentEvent } from "../infra/agent-events.js"; import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js";
+6 -2
View File
@@ -109,14 +109,18 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
state: "processing", state: "processing",
reason: wasActive ? "run_replaced" : "run_started", reason: wasActive ? "run_replaced" : "run_started",
}); });
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); if (!sessionId.startsWith("probe-")) {
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
} }
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) { export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) { if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
ACTIVE_EMBEDDED_RUNS.delete(sessionId); ACTIVE_EMBEDDED_RUNS.delete(sessionId);
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" }); logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); if (!sessionId.startsWith("probe-")) {
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
notifyEmbeddedRunEnded(sessionId); notifyEmbeddedRunEnded(sessionId);
} else { } else {
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`); diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js"; import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools(); const defaultTools = createClawdbotCodingTools();
@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import sharp from "sharp"; import sharp from "sharp";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js"; import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools(); const defaultTools = createClawdbotCodingTools();
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js"; import { createClawdbotCodingTools } from "./pi-tools.js";
describe("createClawdbotCodingTools", () => { describe("createClawdbotCodingTools", () => {
@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createSandboxedReadTool } from "./pi-tools.read.js"; import { createSandboxedReadTool } from "./pi-tools.read.js";
+34
View File
@@ -0,0 +1,34 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { acquireSessionWriteLock } from "./session-write-lock.js";
describe("acquireSessionWriteLock", () => {
it("reuses locks across symlinked session paths", async () => {
if (process.platform === "win32") {
expect(true).toBe(true);
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const realDir = path.join(root, "real");
const linkDir = path.join(root, "link");
await fs.mkdir(realDir, { recursive: true });
await fs.symlink(realDir, linkDir);
const sessionReal = path.join(realDir, "sessions.json");
const sessionLink = path.join(linkDir, "sessions.json");
const lockA = await acquireSessionWriteLock({ sessionFile: sessionReal, timeoutMs: 500 });
const lockB = await acquireSessionWriteLock({ sessionFile: sessionLink, timeoutMs: 500 });
await lockB.release();
await lockA.release();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
+17 -9
View File
@@ -45,20 +45,28 @@ export async function acquireSessionWriteLock(params: {
}> { }> {
const timeoutMs = params.timeoutMs ?? 10_000; const timeoutMs = params.timeoutMs ?? 10_000;
const staleMs = params.staleMs ?? 30 * 60 * 1000; const staleMs = params.staleMs ?? 30 * 60 * 1000;
const sessionFile = params.sessionFile; const sessionFile = path.resolve(params.sessionFile);
const lockPath = `${sessionFile}.lock`; const sessionDir = path.dirname(sessionFile);
await fs.mkdir(path.dirname(lockPath), { recursive: true }); await fs.mkdir(sessionDir, { recursive: true });
let normalizedDir = sessionDir;
try {
normalizedDir = await fs.realpath(sessionDir);
} catch {
// Fall back to the resolved path if realpath fails (permissions, transient FS).
}
const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile));
const lockPath = `${normalizedSessionFile}.lock`;
const held = HELD_LOCKS.get(sessionFile); const held = HELD_LOCKS.get(normalizedSessionFile);
if (held) { if (held) {
held.count += 1; held.count += 1;
return { return {
release: async () => { release: async () => {
const current = HELD_LOCKS.get(sessionFile); const current = HELD_LOCKS.get(normalizedSessionFile);
if (!current) return; if (!current) return;
current.count -= 1; current.count -= 1;
if (current.count > 0) return; if (current.count > 0) return;
HELD_LOCKS.delete(sessionFile); HELD_LOCKS.delete(normalizedSessionFile);
await current.handle.close(); await current.handle.close();
await fs.rm(current.lockPath, { force: true }); await fs.rm(current.lockPath, { force: true });
}, },
@@ -75,14 +83,14 @@ export async function acquireSessionWriteLock(params: {
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2), JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
"utf8", "utf8",
); );
HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath }); HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath });
return { return {
release: async () => { release: async () => {
const current = HELD_LOCKS.get(sessionFile); const current = HELD_LOCKS.get(normalizedSessionFile);
if (!current) return; if (!current) return;
current.count -= 1; current.count -= 1;
if (current.count > 0) return; if (current.count > 0) return;
HELD_LOCKS.delete(sessionFile); HELD_LOCKS.delete(normalizedSessionFile);
await current.handle.close(); await current.handle.close();
await fs.rm(current.lockPath, { force: true }); await fs.rm(current.lockPath, { force: true });
}, },
@@ -0,0 +1,22 @@
import { vi } from "vitest";
const stubTool = (name: string) => ({
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
});
vi.mock("../tools/image-tool.js", () => ({
createImageTool: () => stubTool("image"),
}));
vi.mock("../tools/web-tools.js", () => ({
createWebSearchTool: () => null,
createWebFetchTool: () => null,
}));
vi.mock("../../plugins/tools.js", () => ({
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
}));
@@ -0,0 +1,30 @@
import { vi } from "vitest";
const stubTool = (name: string) => ({
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
});
vi.mock("../tools/browser-tool.js", () => ({
createBrowserTool: () => stubTool("browser"),
}));
vi.mock("../tools/canvas-tool.js", () => ({
createCanvasTool: () => stubTool("canvas"),
}));
vi.mock("../tools/image-tool.js", () => ({
createImageTool: () => stubTool("image"),
}));
vi.mock("../tools/web-tools.js", () => ({
createWebSearchTool: () => null,
createWebFetchTool: () => null,
}));
vi.mock("../../plugins/tools.js", () => ({
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
}));
-122
View File
@@ -1,122 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import type { MsgContext } from "./templating.js";
describe("resolveCommandAuthorization", () => {
it("falls back from empty SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from whitespace SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+999"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: " ",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+999");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from un-normalizable SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "wat",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:120363401234567890@g.us",
SenderId: "123@lid",
SenderE164: "+41796666864",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+41796666864");
expect(auth.isAuthorizedSender).toBe(true);
});
});
@@ -1,10 +1,14 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js"; import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js";
import { listChatCommands } from "./commands-registry.js"; import { listChatCommands } from "./commands-registry.js";
import { parseActivationCommand } from "./group-activation.js"; import { parseActivationCommand } from "./group-activation.js";
import { parseSendPolicyCommand } from "./send-policy.js"; import { parseSendPolicyCommand } from "./send-policy.js";
import { setActivePluginRegistry } from "../plugins/runtime.js"; import type { MsgContext } from "./templating.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
beforeEach(() => { beforeEach(() => {
setActivePluginRegistry(createTestRegistry([])); setActivePluginRegistry(createTestRegistry([]));
@@ -14,6 +18,123 @@ afterEach(() => {
setActivePluginRegistry(createTestRegistry([])); setActivePluginRegistry(createTestRegistry([]));
}); });
describe("resolveCommandAuthorization", () => {
it("falls back from empty SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from whitespace SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+999"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: " ",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+999");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from un-normalizable SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "wat",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:120363401234567890@g.us",
SenderId: "123@lid",
SenderE164: "+41796666864",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+41796666864");
expect(auth.isAuthorizedSender).toBe(true);
});
});
describe("control command parsing", () => { describe("control command parsing", () => {
it("requires slash for send policy", () => { it("requires slash for send policy", () => {
expect(parseSendPolicyCommand("/send on")).toEqual({ expect(parseSendPolicyCommand("/send on")).toEqual({
@@ -1,156 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildCommandTextFromArgs,
parseCommandArgs,
resolveCommandArgMenu,
serializeCommandArgs,
} from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
describe("commands registry args", () => {
it("parses positional args and captureRemaining", () => {
const command: ChatCommandDefinition = {
key: "debug",
description: "debug",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [
{ name: "action", description: "action", type: "string" },
{ name: "path", description: "path", type: "string" },
{ name: "value", description: "value", type: "string", captureRemaining: true },
],
};
const args = parseCommandArgs(command, "set foo bar baz");
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
});
it("serializes args via raw first, then values", () => {
const command: ChatCommandDefinition = {
key: "model",
description: "model",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
};
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"gpt-5.2-codex",
);
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"/model gpt-5.2-codex",
);
});
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { values: { mode: "tokens" } },
cfg: {} as never,
});
expect(menu).toBeNull();
});
it("resolves function-based choices with a default provider/model context", () => {
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
null;
const command: ChatCommandDefinition = {
key: "think",
description: "think",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "level",
description: "level",
type: "string",
choices: ({ provider, model, command, arg }) => {
seen = { provider, model, commandKey: command.key, argName: arg.name };
return ["low", "high"];
},
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual(["low", "high"]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();
expect(seen?.model).toBeTruthy();
});
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "none",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { raw: "on" },
cfg: {} as never,
});
expect(menu).toBeNull();
});
});
+152
View File
@@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { import {
buildCommandText, buildCommandText,
buildCommandTextFromArgs,
getCommandDetection, getCommandDetection,
listChatCommands, listChatCommands,
listChatCommandsForConfig, listChatCommandsForConfig,
listNativeCommandSpecs, listNativeCommandSpecs,
listNativeCommandSpecsForConfig, listNativeCommandSpecsForConfig,
normalizeCommandBody, normalizeCommandBody,
parseCommandArgs,
resolveCommandArgMenu,
serializeCommandArgs,
shouldHandleTextCommands, shouldHandleTextCommands,
} from "./commands-registry.js"; } from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
import { setActivePluginRegistry } from "../plugins/runtime.js"; import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js";
@@ -154,3 +159,150 @@ describe("commands registry", () => {
expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram"); expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram");
}); });
}); });
describe("commands registry args", () => {
it("parses positional args and captureRemaining", () => {
const command: ChatCommandDefinition = {
key: "debug",
description: "debug",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [
{ name: "action", description: "action", type: "string" },
{ name: "path", description: "path", type: "string" },
{ name: "value", description: "value", type: "string", captureRemaining: true },
],
};
const args = parseCommandArgs(command, "set foo bar baz");
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
});
it("serializes args via raw first, then values", () => {
const command: ChatCommandDefinition = {
key: "model",
description: "model",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
};
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"gpt-5.2-codex",
);
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"/model gpt-5.2-codex",
);
});
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { values: { mode: "tokens" } },
cfg: {} as never,
});
expect(menu).toBeNull();
});
it("resolves function-based choices with a default provider/model context", () => {
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
null;
const command: ChatCommandDefinition = {
key: "think",
description: "think",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "level",
description: "level",
type: "string",
choices: ({ provider, model, command, arg }) => {
seen = { provider, model, commandKey: command.key, argName: arg.name };
return ["low", "high"];
},
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual(["low", "high"]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();
expect(seen?.model).toBeTruthy();
});
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "none",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { raw: "on" },
cfg: {} as never,
});
expect(menu).toBeNull();
});
});
+78 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "./heartbeat.js"; import {
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
isHeartbeatContentEffectivelyEmpty,
stripHeartbeatToken,
} from "./heartbeat.js";
import { HEARTBEAT_TOKEN } from "./tokens.js"; import { HEARTBEAT_TOKEN } from "./tokens.js";
describe("stripHeartbeatToken", () => { describe("stripHeartbeatToken", () => {
@@ -105,3 +109,76 @@ describe("stripHeartbeatToken", () => {
}); });
}); });
}); });
describe("isHeartbeatContentEffectivelyEmpty", () => {
it("returns false for undefined/null (missing file should not skip)", () => {
expect(isHeartbeatContentEffectivelyEmpty(undefined)).toBe(false);
expect(isHeartbeatContentEffectivelyEmpty(null)).toBe(false);
});
it("returns true for empty string", () => {
expect(isHeartbeatContentEffectivelyEmpty("")).toBe(true);
});
it("returns true for whitespace only", () => {
expect(isHeartbeatContentEffectivelyEmpty(" ")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("\n\n\n")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty(" \n \n ")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("\t\t")).toBe(true);
});
it("returns true for header-only content", () => {
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n")).toBe(true);
});
it("returns true for comments only", () => {
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
});
it("returns true for default template content (header + comment)", () => {
const defaultTemplate = `# HEARTBEAT.md
Keep this file empty unless you want a tiny checklist. Keep it small.
`;
// Note: The template has actual text content, so it's NOT effectively empty
expect(isHeartbeatContentEffectivelyEmpty(defaultTemplate)).toBe(false);
});
it("returns true for header with only empty lines", () => {
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n\n")).toBe(true);
});
it("returns false when actionable content exists", () => {
expect(isHeartbeatContentEffectivelyEmpty("- Check email")).toBe(false);
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n- Task 1")).toBe(false);
expect(isHeartbeatContentEffectivelyEmpty("Remind me to call mom")).toBe(false);
});
it("returns false for content with tasks after header", () => {
const content = `# HEARTBEAT.md
- Task 1
- Task 2
`;
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
});
it("returns false for mixed content with non-comment text", () => {
const content = `# HEARTBEAT.md
## Tasks
Check the server logs
`;
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
});
it("treats markdown headers as comments (effectively empty)", () => {
const content = `# HEARTBEAT.md
## Section 1
### Subsection
`;
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(true);
});
});
+32
View File
@@ -7,6 +7,38 @@ export const HEARTBEAT_PROMPT =
export const DEFAULT_HEARTBEAT_EVERY = "30m"; export const DEFAULT_HEARTBEAT_EVERY = "30m";
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300; export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
/**
* Check if HEARTBEAT.md content is "effectively empty" - meaning it has no actionable tasks.
* This allows skipping heartbeat API calls when no tasks are configured.
*
* A file is considered effectively empty if it contains only:
* - Whitespace
* - Comment lines (lines starting with #)
* - Empty lines
*
* Note: A missing file returns false (not effectively empty) so the LLM can still
* decide what to do. This function is only for when the file exists but has no content.
*/
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
if (content === undefined || content === null) return false;
if (typeof content !== "string") return false;
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines
if (!trimmed) continue;
// Skip markdown header lines (# followed by space or EOL, ## etc)
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
// (Those aren't valid markdown headers - ATX headers require space after #)
if (/^#+(\s|$)/.test(trimmed)) continue;
// Found a non-empty, non-comment line - there's actionable content
return false;
}
// All lines were either empty or comments
return true;
}
export function resolveHeartbeatPrompt(raw?: string): string { export function resolveHeartbeatPrompt(raw?: string): string {
const trimmed = typeof raw === "string" ? raw.trim() : ""; const trimmed = typeof raw === "string" ? raw.trim() : "";
return trimmed || HEARTBEAT_PROMPT; return trimmed || HEARTBEAT_PROMPT;
-48
View File
@@ -1,48 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createInboundDebouncer } from "./inbound-debounce.js";
describe("createInboundDebouncer", () => {
it("debounces and combines items", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
debounceMs: 10,
buildKey: (item) => item.key,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1" });
await debouncer.enqueue({ key: "a", id: "2" });
expect(calls).toEqual([]);
await vi.advanceTimersByTimeAsync(10);
expect(calls).toEqual([["1", "2"]]);
vi.useRealTimers();
});
it("flushes buffered items before non-debounced item", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
debounceMs: 50,
buildKey: (item) => item.key,
shouldDebounce: (item) => item.debounce,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
expect(calls).toEqual([["1"], ["2"]]);
vi.useRealTimers();
});
});
+402
View File
@@ -0,0 +1,402 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { GroupKeyResolution } from "../config/sessions.js";
import { createInboundDebouncer } from "./inbound-debounce.js";
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";
import { finalizeInboundContext } from "./reply/inbound-context.js";
import {
buildInboundDedupeKey,
resetInboundDedupe,
shouldSkipDuplicateInbound,
} from "./reply/inbound-dedupe.js";
import { formatInboundBodyWithSenderMeta } from "./reply/inbound-sender-meta.js";
import { normalizeInboundTextNewlines } from "./reply/inbound-text.js";
import { resolveGroupRequireMention } from "./reply/groups.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
normalizeMentionText,
} from "./reply/mentions.js";
import { initSessionState } from "./reply/session.js";
describe("applyTemplate", () => {
it("renders primitive values", () => {
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
const overrides = ctx as Record<string, unknown>;
overrides.MessageSid = 42;
overrides.IsNewSession = true;
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
});
it("renders arrays of primitives", () => {
const ctx = { MediaPaths: ["a"] } as TemplateContext;
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
});
it("drops object values", () => {
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
});
it("renders missing placeholders as empty", () => {
const ctx: TemplateContext = {};
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
});
});
describe("normalizeInboundTextNewlines", () => {
it("keeps real newlines", () => {
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
});
it("normalizes CRLF/CR to LF", () => {
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
});
it("decodes literal \\n to newlines when no real newlines exist", () => {
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
});
});
describe("finalizeInboundContext", () => {
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
const ctx: MsgContext = {
Body: "a\\nb\r\nc",
RawBody: "raw\\nline",
ChatType: "channel",
From: "whatsapp:group:123@g.us",
GroupSubject: "Test",
};
const out = finalizeInboundContext(ctx);
expect(out.Body).toBe("a\nb\nc");
expect(out.RawBody).toBe("raw\nline");
expect(out.BodyForAgent).toBe("a\nb\nc");
expect(out.BodyForCommands).toBe("raw\nline");
expect(out.CommandAuthorized).toBe(false);
expect(out.ChatType).toBe("channel");
expect(out.ConversationLabel).toContain("Test");
});
it("can force BodyForCommands to follow updated CommandBody", () => {
const ctx: MsgContext = {
Body: "base",
BodyForCommands: "<media:audio>",
CommandBody: "say hi",
From: "signal:+15550001111",
ChatType: "direct",
};
finalizeInboundContext(ctx, { forceBodyForCommands: true });
expect(ctx.BodyForCommands).toBe("say hi");
});
});
describe("formatInboundBodyWithSenderMeta", () => {
it("does nothing for direct messages", () => {
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
});
it("appends a sender meta line for non-direct messages", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
"[X] hi\n[from: Alice (A1)]",
);
});
it("prefers SenderE164 in the label when present", () => {
const ctx: MsgContext = {
ChatType: "group",
SenderName: "Bob",
SenderId: "bob@s.whatsapp.net",
SenderE164: "+222",
};
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
"[X] hi\n[from: Bob (+222)]",
);
});
it("appends with a real newline even if the body contains literal \\n", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
"[X] one\\n[X] two\n[from: Bob (+222)]",
);
});
it("does not duplicate a sender meta line when one is already present", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
"[X] hi\n[from: Alice (A1)]",
);
});
it("does not append when the body already includes a sender prefix", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
});
it("does not append when the sender prefix follows an envelope header", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
"[Signal Group] Alice (A1): hi",
);
});
});
describe("inbound dedupe", () => {
it("builds a stable key when MessageSid is present", () => {
const ctx: MsgContext = {
Provider: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:123",
MessageSid: "42",
};
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
});
it("skips duplicates with the same key", () => {
resetInboundDedupe();
const ctx: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+1555",
MessageSid: "msg-1",
};
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
});
it("does not dedupe when the peer changes", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
).toBe(false);
});
it("does not dedupe across session keys", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+1555",
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
).toBe(true);
});
});
describe("createInboundDebouncer", () => {
it("debounces and combines items", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
debounceMs: 10,
buildKey: (item) => item.key,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1" });
await debouncer.enqueue({ key: "a", id: "2" });
expect(calls).toEqual([]);
await vi.advanceTimersByTimeAsync(10);
expect(calls).toEqual([["1", "2"]]);
vi.useRealTimers();
});
it("flushes buffered items before non-debounced item", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
debounceMs: 50,
buildKey: (item) => item.key,
shouldDebounce: (item) => item.debounce,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
expect(calls).toEqual([["1"], ["2"]]);
vi.useRealTimers();
});
});
describe("initSessionState sender meta", () => {
it("injects sender meta into BodyStripped for group chats", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: {
Body: "[WhatsApp 123@g.us] ping",
ChatType: "group",
SenderName: "Bob",
SenderE164: "+222",
SenderId: "222@s.whatsapp.net",
SessionKey: "agent:main:whatsapp:group:123@g.us",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
});
it("does not inject sender meta for direct chats", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: {
Body: "[WhatsApp +1] ping",
ChatType: "direct",
SenderName: "Bob",
SenderE164: "+222",
SessionKey: "agent:main:whatsapp:dm:+222",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
});
});
describe("mention helpers", () => {
it("builds regexes and skips invalid patterns", () => {
const regexes = buildMentionRegexes({
messages: {
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
},
});
expect(regexes).toHaveLength(1);
expect(regexes[0]?.test("clawd")).toBe(true);
});
it("normalizes zero-width characters", () => {
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
});
it("matches patterns case-insensitively", () => {
const regexes = buildMentionRegexes({
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
messages: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
},
agents: {
list: [
{
id: "work",
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
},
],
},
},
"work",
);
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
});
});
describe("resolveGroupRequireMention", () => {
it("respects Discord guild/channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
channels: {
discord: {
guilds: {
"145": {
requireMention: false,
channels: {
general: { allow: true },
},
},
},
},
},
};
const ctx: TemplateContext = {
Provider: "discord",
From: "discord:group:123",
GroupChannel: "#general",
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
channel: "discord",
id: "123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("respects Slack channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
channels: {
slack: {
channels: {
C123: { requireMention: false },
},
},
},
};
const ctx: TemplateContext = {
Provider: "slack",
From: "slack:channel:C123",
GroupSubject: "#general",
};
const groupResolution: GroupKeyResolution = {
channel: "slack",
id: "C123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
});
@@ -2,174 +2,79 @@ import fs from "node:fs/promises";
import { basename, join } from "node:path"; import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { MsgContext, TemplateContext } from "./templating.js";
vi.mock("../agents/pi-embedded.js", () => ({ const sandboxMocks = vi.hoisted(() => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false), ensureSandboxWorkspaceForSession: vi.fn(),
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
})); }));
const usageMocks = vi.hoisted(() => ({ vi.mock("../agents/sandbox.js", () => sandboxMocks);
loadProviderUsageSummary: vi.fn().mockResolvedValue({
updatedAt: 0,
providers: [],
}),
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
}));
vi.mock("../infra/provider-usage.js", () => usageMocks);
const modelCatalogMocks = vi.hoisted(() => ({
loadModelCatalog: vi.fn().mockResolvedValue([
{
provider: "anthropic",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
contextWindow: 200000,
},
{
provider: "openrouter",
id: "anthropic/claude-opus-4-5",
name: "Claude Opus 4.5 (OpenRouter)",
contextWindow: 200000,
},
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
{ provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" },
{ provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" },
]),
resetModelCatalogCacheForTest: vi.fn(),
}));
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { resolveAgentIdFromSessionKey, resolveSessionKey } from "../config/sessions.js"; import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
const webMocks = vi.hoisted(() => ({
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
}));
vi.mock("../web/session.js", () => webMocks);
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase( return withTempHomeBase(async (home) => await fn(home), { prefix: "clawdbot-triggers-" });
async (home) => {
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(abortEmbeddedPiRun).mockClear();
return await fn(home);
},
{ prefix: "clawdbot-triggers-" },
);
}
function _makeCfg(home: string) {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: { store: join(home, "sessions.json") },
};
} }
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("trigger handling", () => { describe("stageSandboxMedia", () => {
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => { it("stages inbound media into the sandbox workspace", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const inboundDir = join(home, ".clawdbot", "media", "inbound"); const inboundDir = join(home, ".clawdbot", "media", "inbound");
await fs.mkdir(inboundDir, { recursive: true }); await fs.mkdir(inboundDir, { recursive: true });
const mediaPath = join(inboundDir, "photo.jpg"); const mediaPath = join(inboundDir, "photo.jpg");
await fs.writeFile(mediaPath, "test"); await fs.writeFile(mediaPath, "test");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ const sandboxDir = join(home, "sandboxes", "session");
payloads: [{ text: "ok" }], vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
meta: { workspaceDir: sandboxDir,
durationMs: 1, containerWorkdir: "/work",
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
}); });
const cfg = { const ctx: MsgContext = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: {
store: join(home, "sessions.json"),
},
};
const ctx = {
Body: "hi", Body: "hi",
From: "whatsapp:group:demo", From: "whatsapp:group:demo",
To: "+2000", To: "+2000",
ChatType: "group" as const, ChatType: "group",
Provider: "whatsapp" as const, Provider: "whatsapp",
MediaPath: mediaPath, MediaPath: mediaPath,
MediaType: "image/jpeg", MediaType: "image/jpeg",
MediaUrl: mediaPath, MediaUrl: mediaPath,
}; };
const sessionCtx: TemplateContext = { ...ctx };
const res = await getReplyFromConfig(ctx, {}, cfg); await stageSandboxMedia({
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const stagedPath = `media/inbound/${basename(mediaPath)}`;
expect(prompt).toContain(stagedPath);
expect(prompt).not.toContain(mediaPath);
const sessionKey = resolveSessionKey(
cfg.session?.scope ?? "per-sender",
ctx, ctx,
cfg.session?.mainKey, sessionCtx,
); cfg: {
const agentId = resolveAgentIdFromSessionKey(sessionKey); agents: {
const sandbox = await ensureSandboxWorkspaceForSession({ defaults: {
config: cfg, model: "anthropic/claude-opus-4-5",
sessionKey, workspace: join(home, "clawd"),
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), sandbox: {
mode: "non-main",
workspaceRoot: join(home, "sandboxes"),
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: join(home, "sessions.json") },
},
sessionKey: "agent:main:main",
workspaceDir: join(home, "clawd"),
}); });
expect(sandbox).not.toBeNull();
if (!sandbox) { const stagedPath = `media/inbound/${basename(mediaPath)}`;
throw new Error("Expected sandbox to be set"); expect(ctx.MediaPath).toBe(stagedPath);
} expect(sessionCtx.MediaPath).toBe(stagedPath);
const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath)); expect(ctx.MediaUrl).toBe(stagedPath);
expect(sessionCtx.MediaUrl).toBe(stagedPath);
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(mediaPath));
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy(); await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
}); });
}); });
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseAudioTag } from "./audio-tags.js";
describe("parseAudioTag", () => {
it("detects audio_as_voice and strips the tag", () => {
const result = parseAudioTag("Hello [[audio_as_voice]] world");
expect(result.audioAsVoice).toBe(true);
expect(result.hadTag).toBe(true);
expect(result.text).toBe("Hello world");
});
it("returns empty output for missing text", () => {
const result = parseAudioTag(undefined);
expect(result.audioAsVoice).toBe(false);
expect(result.hadTag).toBe(false);
expect(result.text).toBe("");
});
it("removes tag-only messages", () => {
const result = parseAudioTag("[[audio_as_voice]]");
expect(result.audioAsVoice).toBe(true);
expect(result.text).toBe("");
});
});
@@ -1,71 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
describe("block reply coalescer", () => {
afterEach(() => {
vi.useRealTimers();
});
it("coalesces chunks within the idle window", async () => {
vi.useFakeTimers();
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "Hello" });
coalescer.enqueue({ text: "world" });
await vi.advanceTimersByTimeAsync(100);
expect(flushes).toEqual(["Hello world"]);
coalescer.stop();
});
it("waits until minChars before idle flush", async () => {
vi.useFakeTimers();
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "short" });
await vi.advanceTimersByTimeAsync(50);
expect(flushes).toEqual([]);
coalescer.enqueue({ text: "message" });
await vi.advanceTimersByTimeAsync(50);
expect(flushes).toEqual(["short message"]);
coalescer.stop();
});
it("flushes buffered text before media payloads", () => {
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
},
});
coalescer.enqueue({ text: "Hello" });
coalescer.enqueue({ text: "world" });
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
void coalescer.flush({ force: true });
expect(flushes[0].text).toBe("Hello world");
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
coalescer.stop();
});
});
@@ -1,139 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
};
});
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../pairing/pairing-store.js", async () => {
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
"../../pairing/pairing-store.js",
);
return {
...actual,
readChannelAllowFromStore: readChannelAllowFromStoreMock,
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
};
});
vi.mock("../../channels/plugins/pairing.js", async () => {
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
"../../channels/plugins/pairing.js",
);
return {
...actual,
listPairingChannels: () => ["telegram"],
};
});
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "telegram",
Surface: "telegram",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "telegram",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /allowlist", () => {
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist list dm", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram");
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds entries to config and pairing store", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist add dm 789", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: { telegram: { allowFrom: ["123", "789"] } },
}),
);
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
});
expect(result.reply?.text).toContain("DM allowlist added");
});
});
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more