Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fbf728298 | ||
|
|
16c4a3aea6 | ||
|
|
242add587f | ||
|
|
6fba598eaf | ||
|
|
75a54f0259 | ||
|
|
c63144ab14 | ||
|
|
f07c39b265 | ||
|
|
40181afded | ||
|
|
2f1b9efe9a | ||
|
|
ff30cef8a4 | ||
|
|
3d958d5466 | ||
|
|
cad7ed1cb8 | ||
|
|
8195497cec | ||
|
|
1af227b619 | ||
|
|
b77e730657 | ||
|
|
37e5f077b8 | ||
|
|
0eb7e1864c | ||
|
|
0d336272f9 | ||
|
|
ace6a42ea6 | ||
|
|
6d2a1ce217 | ||
|
|
c9d73469c3 | ||
|
|
29353e2e81 | ||
|
|
fdc50a0feb | ||
|
|
a1413a011e | ||
|
|
bfbeea0f20 | ||
|
|
2c85b1b409 | ||
|
|
8b7b7e154f | ||
|
|
bb9bddebb4 | ||
|
|
6e570561b6 | ||
|
|
fb6363ae58 | ||
|
|
1b77e086d4 | ||
|
|
d371a4c8c3 | ||
|
|
03e8b7c4ba | ||
|
|
8aadcaa1bd | ||
|
|
96800c27ec | ||
|
|
13d1712850 | ||
|
|
c5546f0d5b | ||
|
|
3de5ea818d | ||
|
|
dc07f1e021 | ||
|
|
310a248a44 | ||
|
|
88e7684258 | ||
|
|
716f901504 | ||
|
|
e817c0cee5 | ||
|
|
e634791585 | ||
|
|
78071f8ec4 | ||
|
|
dc89bc4004 |
No files matched your search
@@ -29,5 +29,6 @@ jobs:
|
|||||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||||
CLAWDBOT_NO_ONBOARD: "1"
|
CLAWDBOT_NO_ONBOARD: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||||
|
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||||
run: pnpm test:install:smoke
|
run: pnpm test:install:smoke
|
||||||
@@ -22,6 +22,15 @@
|
|||||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||||
|
|
||||||
|
## exe.dev VM ops (general)
|
||||||
|
- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal).
|
||||||
|
- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
|
||||||
|
- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset.
|
||||||
|
- Restart: exe.dev often lacks systemd user bus; stop old gateway and run:
|
||||||
|
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
|
||||||
|
- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`.
|
||||||
|
- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
@@ -119,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.
|
||||||
|
|||||||
@@ -2,6 +2,25 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.23
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||||
|
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||||
|
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- macOS: sync voice wake trigger words on submit/blur in settings. (#1506) Thanks @shiv19.
|
||||||
|
- 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.
|
||||||
|
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||||
|
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
|
||||||
|
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||||
|
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||||
|
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
@@ -35,6 +54,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||||
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
||||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||||
|
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
|
||||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
||||||
|
|
||||||
|
|||||||
+48
-16
@@ -2,6 +2,54 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.22</title>
|
||||||
|
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>7530</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.22</h2>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.</li>
|
||||||
|
<li>Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.</li>
|
||||||
|
<li>Slack: add chat-type reply threading overrides via <code>replyToModeByChatType</code>. (#1442) Thanks @stefangalescu.</li>
|
||||||
|
<li>BlueBubbles: add <code>asVoice</code> support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.</li>
|
||||||
|
<li>Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.</li>
|
||||||
|
<li>Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.</li>
|
||||||
|
<li>Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.</li>
|
||||||
|
<li>Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.</li>
|
||||||
|
<li>Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.</li>
|
||||||
|
<li>Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.</li>
|
||||||
|
<li>Gateway: stop the service before uninstalling and fail if it remains loaded.</li>
|
||||||
|
<li>Agents: surface concrete API error details instead of generic AI service errors.</li>
|
||||||
|
<li>Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)</li>
|
||||||
|
<li>Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.</li>
|
||||||
|
<li>Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.</li>
|
||||||
|
<li>Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.</li>
|
||||||
|
<li>Agents: make tool summaries more readable and only show optional params when set.</li>
|
||||||
|
<li>Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.</li>
|
||||||
|
<li>Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.</li>
|
||||||
|
<li>CLI: prefer <code>~</code> for home paths in output.</li>
|
||||||
|
<li>Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.</li>
|
||||||
|
<li>Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.</li>
|
||||||
|
<li>Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.</li>
|
||||||
|
<li>Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.</li>
|
||||||
|
<li>Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.</li>
|
||||||
|
<li>Slack: reduce WebClient retries to avoid duplicate sends. (#1481)</li>
|
||||||
|
<li>Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.</li>
|
||||||
|
<li>macOS: prefer linked channels in gateway summary to avoid false “not linked” status.</li>
|
||||||
|
<li>macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.22/Clawdbot-2026.1.22.zip" length="22302446" type="application/octet-stream" sparkle:edSignature="w/EzfwGBCRRuCg5vz8enIfYujxOZJWRw9PaunQ7gIafKwnBJSTtxcnkvMVwQsnBwB6VN5Tu2MPij7PjDFFX+CA=="/>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.21</title>
|
<title>2026.1.21</title>
|
||||||
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
||||||
@@ -266,21 +314,5 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
|||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<title>2026.1.16-2</title>
|
|
||||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>6273</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
|
||||||
</item>
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -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,8 @@ 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] = []
|
||||||
|
@FocusState private var focusedTriggerEntryID: UUID?
|
||||||
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 +33,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 +107,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 +125,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 +141,21 @@ 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()
|
||||||
|
}
|
||||||
|
.onChange(of: self.state.swabbleTriggerWords) { _, _ in
|
||||||
|
guard !self.isPreview else { return }
|
||||||
|
guard self.focusedTriggerEntryID == nil else { return }
|
||||||
|
self.loadTriggerEntries()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +169,47 @@ 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
|
.focused(self.$focusedTriggerEntryID, equals: entry.id)
|
||||||
Button {
|
.onSubmit {
|
||||||
self.removeWord(at: row.id)
|
self.syncTriggerEntriesToState()
|
||||||
} 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)
|
.onChange(of: self.focusedTriggerEntryID) { oldValue, newValue in
|
||||||
|
guard oldValue != nil, oldValue != newValue else { return }
|
||||||
|
self.syncTriggerEntriesToState()
|
||||||
|
}
|
||||||
|
.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 +244,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 +659,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 +676,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
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
|
|||||||
|
|
||||||
## Prereqs
|
## Prereqs
|
||||||
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
|
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
|
||||||
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist).
|
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`.
|
||||||
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
|
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
|
||||||
- We use a Keychain profile named `clawdbot-notary`, created from App Store Connect API key env vars in your shell profile:
|
- We use a Keychain profile named `clawdbot-notary`, created from App Store Connect API key env vars in your shell profile:
|
||||||
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
|
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
|
|||||||
```
|
```
|
||||||
|
|
||||||
Other config knobs that affect tool availability:
|
Other config knobs that affect tool availability:
|
||||||
|
- Allowlists that only name plugin tools are treated as plugin opt-ins; core tools remain
|
||||||
|
enabled unless you also include core tools or groups in the allowlist.
|
||||||
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
|
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
|
||||||
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
|
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
|
||||||
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
|
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
|||||||
## Operator trigger
|
## Operator trigger
|
||||||
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
||||||
- Read this doc and `docs/platforms/mac/release.md`.
|
- Read this doc and `docs/platforms/mac/release.md`.
|
||||||
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
|
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
|
||||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||||
|
|
||||||
1) **Version & metadata**
|
1) **Version & metadata**
|
||||||
@@ -39,8 +39,9 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||||||
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
||||||
- [ ] `pnpm run build` (last sanity check after tests)
|
- [ ] `pnpm run build` (last sanity check after tests)
|
||||||
- [ ] `pnpm release:check` (verifies npm pack contents)
|
- [ ] `pnpm release:check` (verifies npm pack contents)
|
||||||
- [ ] `pnpm test:install:smoke` (Docker install smoke test; required before release)
|
- [ ] `CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
|
||||||
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
||||||
|
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
|
||||||
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
|
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
|
||||||
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
|
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
|
||||||
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
|
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
|
|||||||
|
|
||||||
You can also allow it globally with `tools.allow` if every agent should see it.
|
You can also allow it globally with `tools.allow` if every agent should see it.
|
||||||
|
|
||||||
|
Note: allowlists are opt-in for optional plugins. If your allowlist only names
|
||||||
|
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core
|
||||||
|
tools, include the core tools or groups you want in the allowlist too.
|
||||||
|
|
||||||
## Example: Email triage
|
## Example: Email triage
|
||||||
|
|
||||||
Without Lobster:
|
Without Lobster:
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ echo "==> Verify git installed"
|
|||||||
command -v git >/dev/null
|
command -v git >/dev/null
|
||||||
|
|
||||||
echo "==> Verify clawdbot installed"
|
echo "==> Verify clawdbot installed"
|
||||||
LATEST_VERSION="$(npm view clawdbot version)"
|
EXPECTED_VERSION="${CLAWDBOT_INSTALL_EXPECT_VERSION:-}"
|
||||||
|
if [[ -n "$EXPECTED_VERSION" ]]; then
|
||||||
|
LATEST_VERSION="$EXPECTED_VERSION"
|
||||||
|
else
|
||||||
|
LATEST_VERSION="$(npm view clawdbot version)"
|
||||||
|
fi
|
||||||
CMD_PATH="$(command -v clawdbot || true)"
|
CMD_PATH="$(command -v clawdbot || true)"
|
||||||
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/clawdbot" ]]; then
|
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/clawdbot" ]]; then
|
||||||
CMD_PATH="$HOME/.npm-global/bin/clawdbot"
|
CMD_PATH="$HOME/.npm-global/bin/clawdbot"
|
||||||
|
|||||||
@@ -6,21 +6,36 @@ SMOKE_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}"
|
|||||||
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
|
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
|
||||||
|
|
||||||
echo "==> Resolve npm versions"
|
echo "==> Resolve npm versions"
|
||||||
LATEST_VERSION="$(npm view clawdbot version)"
|
|
||||||
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
|
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
|
||||||
|
LATEST_VERSION="$(npm view clawdbot version)"
|
||||||
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
|
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
|
||||||
else
|
else
|
||||||
PREVIOUS_VERSION="$(node - <<'NODE'
|
VERSIONS_JSON="$(npm view clawdbot versions --json)"
|
||||||
const { execSync } = require("node:child_process");
|
versions_line="$(node - <<'NODE'
|
||||||
|
const raw = process.env.VERSIONS_JSON || "[]";
|
||||||
const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" }));
|
let versions;
|
||||||
if (!Array.isArray(versions) || versions.length === 0) {
|
try {
|
||||||
|
versions = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
versions = raw ? [raw] : [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(versions)) {
|
||||||
|
versions = [versions];
|
||||||
|
}
|
||||||
|
if (versions.length === 0) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const previous = versions.length >= 2 ? versions[versions.length - 2] : versions[0];
|
const latest = versions[versions.length - 1];
|
||||||
process.stdout.write(previous);
|
const previous = versions.length >= 2 ? versions[versions.length - 2] : latest;
|
||||||
|
process.stdout.write(`${latest} ${previous}`);
|
||||||
NODE
|
NODE
|
||||||
)"
|
)"
|
||||||
|
LATEST_VERSION="${versions_line%% *}"
|
||||||
|
PREVIOUS_VERSION="${versions_line#* }"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${CLAWDBOT_INSTALL_LATEST_OUT:-}" ]]; then
|
||||||
|
printf "%s" "$LATEST_VERSION" > "$CLAWDBOT_INSTALL_LATEST_OUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
|
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ SMOKE_IMAGE="${CLAWDBOT_INSTALL_SMOKE_IMAGE:-clawdbot-install-smoke:local}"
|
|||||||
NONROOT_IMAGE="${CLAWDBOT_INSTALL_NONROOT_IMAGE:-clawdbot-install-nonroot:local}"
|
NONROOT_IMAGE="${CLAWDBOT_INSTALL_NONROOT_IMAGE:-clawdbot-install-nonroot:local}"
|
||||||
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
|
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
|
||||||
CLI_INSTALL_URL="${CLAWDBOT_INSTALL_CLI_URL:-https://clawd.bot/install-cli.sh}"
|
CLI_INSTALL_URL="${CLAWDBOT_INSTALL_CLI_URL:-https://clawd.bot/install-cli.sh}"
|
||||||
|
SKIP_NONROOT="${CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT:-0}"
|
||||||
|
LATEST_DIR="$(mktemp -d)"
|
||||||
|
LATEST_FILE="${LATEST_DIR}/latest"
|
||||||
|
|
||||||
echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE"
|
echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE"
|
||||||
docker build \
|
docker build \
|
||||||
@@ -15,31 +18,48 @@ docker build \
|
|||||||
|
|
||||||
echo "==> Run installer smoke test (root): $INSTALL_URL"
|
echo "==> Run installer smoke test (root): $INSTALL_URL"
|
||||||
docker run --rm -t \
|
docker run --rm -t \
|
||||||
|
-v "${LATEST_DIR}:/out" \
|
||||||
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
||||||
|
-e CLAWDBOT_INSTALL_LATEST_OUT="/out/latest" \
|
||||||
-e CLAWDBOT_INSTALL_SMOKE_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}" \
|
-e CLAWDBOT_INSTALL_SMOKE_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}" \
|
||||||
-e CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" \
|
-e CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" \
|
||||||
-e CLAWDBOT_NO_ONBOARD=1 \
|
-e CLAWDBOT_NO_ONBOARD=1 \
|
||||||
-e DEBIAN_FRONTEND=noninteractive \
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
"$SMOKE_IMAGE"
|
"$SMOKE_IMAGE"
|
||||||
|
|
||||||
echo "==> Build non-root image: $NONROOT_IMAGE"
|
LATEST_VERSION=""
|
||||||
docker build \
|
if [[ -f "$LATEST_FILE" ]]; then
|
||||||
-t "$NONROOT_IMAGE" \
|
LATEST_VERSION="$(cat "$LATEST_FILE")"
|
||||||
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
|
fi
|
||||||
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
|
|
||||||
|
|
||||||
echo "==> Run installer non-root test: $INSTALL_URL"
|
if [[ "$SKIP_NONROOT" == "1" ]]; then
|
||||||
docker run --rm -t \
|
echo "==> Skip non-root installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1)"
|
||||||
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
else
|
||||||
-e CLAWDBOT_NO_ONBOARD=1 \
|
echo "==> Build non-root image: $NONROOT_IMAGE"
|
||||||
-e DEBIAN_FRONTEND=noninteractive \
|
docker build \
|
||||||
"$NONROOT_IMAGE"
|
-t "$NONROOT_IMAGE" \
|
||||||
|
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
|
||||||
|
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
|
||||||
|
|
||||||
|
echo "==> Run installer non-root test: $INSTALL_URL"
|
||||||
|
docker run --rm -t \
|
||||||
|
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
|
||||||
|
-e CLAWDBOT_INSTALL_EXPECT_VERSION="$LATEST_VERSION" \
|
||||||
|
-e CLAWDBOT_NO_ONBOARD=1 \
|
||||||
|
-e DEBIAN_FRONTEND=noninteractive \
|
||||||
|
"$NONROOT_IMAGE"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "${CLAWDBOT_INSTALL_SMOKE_SKIP_CLI:-0}" == "1" ]]; then
|
if [[ "${CLAWDBOT_INSTALL_SMOKE_SKIP_CLI:-0}" == "1" ]]; then
|
||||||
echo "==> Skip CLI installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_CLI=1)"
|
echo "==> Skip CLI installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_CLI=1)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_NONROOT" == "1" ]]; then
|
||||||
|
echo "==> Skip CLI installer smoke (non-root image skipped)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Run CLI installer non-root test (same image)"
|
echo "==> Run CLI installer non-root test (same image)"
|
||||||
docker run --rm -t \
|
docker run --rm -t \
|
||||||
--entrypoint /bin/bash \
|
--entrypoint /bin/bash \
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||||
|
|
||||||
@@ -7,17 +8,31 @@ const runs = [
|
|||||||
name: "unit",
|
name: "unit",
|
||||||
args: ["vitest", "run", "--config", "vitest.unit.config.ts"],
|
args: ["vitest", "run", "--config", "vitest.unit.config.ts"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "extensions",
|
||||||
|
args: ["vitest", "run", "--config", "vitest.extensions.config.ts"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "gateway",
|
name: "gateway",
|
||||||
args: ["vitest", "run", "--config", "vitest.gateway.config.ts"],
|
args: ["vitest", "run", "--config", "vitest.gateway.config.ts"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const parallelRuns = runs.filter((entry) => entry.name !== "gateway");
|
||||||
|
const serialRuns = runs.filter((entry) => entry.name === "gateway");
|
||||||
|
|
||||||
const children = new Set();
|
const children = new Set();
|
||||||
|
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
|
||||||
|
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
|
||||||
|
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||||
|
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||||
|
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||||
|
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
|
||||||
|
|
||||||
const run = (entry) =>
|
const run = (entry) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
const child = spawn(pnpm, entry.args, {
|
const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args;
|
||||||
|
const child = spawn(pnpm, args, {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: { ...process.env, VITEST_GROUP: entry.name },
|
env: { ...process.env, VITEST_GROUP: entry.name },
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
@@ -38,6 +53,18 @@ const shutdown = (signal) => {
|
|||||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|
||||||
const codes = await Promise.all(runs.map(run));
|
const parallelCodes = await Promise.all(parallelRuns.map(run));
|
||||||
const failed = codes.find((code) => code !== 0);
|
const failedParallel = parallelCodes.find((code) => code !== 0);
|
||||||
process.exit(failed ?? 0);
|
if (failedParallel !== undefined) {
|
||||||
|
process.exit(failedParallel);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of serialRuns) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const code = await run(entry);
|
||||||
|
if (code !== 0) {
|
||||||
|
process.exit(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
@@ -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", () => ({
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
+1
@@ -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";
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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";
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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";
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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";
|
||||||
|
|
||||||
|
|||||||
+1
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export function isModernModelRef(ref: ModelRef): boolean {
|
|||||||
return matchesPrefix(id, XAI_PREFIXES);
|
return matchesPrefix(id, XAI_PREFIXES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === "opencode" && id.endsWith("-free")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === "openrouter" || provider === "opencode") {
|
if (provider === "openrouter" || provider === "opencode") {
|
||||||
return matchesAny(id, [
|
return matchesAny(id, [
|
||||||
...ANTHROPIC_PREFIXES,
|
...ANTHROPIC_PREFIXES,
|
||||||
|
|||||||
@@ -41,12 +41,11 @@ describe("resolveOpencodeZenAlias", () => {
|
|||||||
describe("resolveOpencodeZenModelApi", () => {
|
describe("resolveOpencodeZenModelApi", () => {
|
||||||
it("maps APIs by model family", () => {
|
it("maps APIs by model family", () => {
|
||||||
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
|
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
|
||||||
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
|
|
||||||
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
||||||
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
||||||
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("glm-4.7")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -55,10 +54,10 @@ describe("getOpencodeZenStaticFallbackModels", () => {
|
|||||||
it("returns an array of models", () => {
|
it("returns an array of models", () => {
|
||||||
const models = getOpencodeZenStaticFallbackModels();
|
const models = getOpencodeZenStaticFallbackModels();
|
||||||
expect(Array.isArray(models)).toBe(true);
|
expect(Array.isArray(models)).toBe(true);
|
||||||
expect(models.length).toBe(11);
|
expect(models.length).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes Claude, GPT, Gemini, GLM, and MiniMax models", () => {
|
it("includes Claude, GPT, Gemini, and GLM models", () => {
|
||||||
const models = getOpencodeZenStaticFallbackModels();
|
const models = getOpencodeZenStaticFallbackModels();
|
||||||
const ids = models.map((m) => m.id);
|
const ids = models.map((m) => m.id);
|
||||||
|
|
||||||
@@ -66,8 +65,7 @@ describe("getOpencodeZenStaticFallbackModels", () => {
|
|||||||
expect(ids).toContain("gpt-5.2");
|
expect(ids).toContain("gpt-5.2");
|
||||||
expect(ids).toContain("gpt-5.1-codex");
|
expect(ids).toContain("gpt-5.1-codex");
|
||||||
expect(ids).toContain("gemini-3-pro");
|
expect(ids).toContain("gemini-3-pro");
|
||||||
expect(ids).toContain("glm-4.7-free");
|
expect(ids).toContain("glm-4.7");
|
||||||
expect(ids).toContain("minimax-m2.1-free");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns valid ModelDefinitionConfig objects", () => {
|
it("returns valid ModelDefinitionConfig objects", () => {
|
||||||
@@ -90,8 +88,7 @@ describe("OPENCODE_ZEN_MODEL_ALIASES", () => {
|
|||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.codex).toBe("gpt-5.1-codex");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.codex).toBe("gpt-5.1-codex");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7-free");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7");
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.minimax).toBe("minimax-m2.1-free");
|
|
||||||
|
|
||||||
// Legacy aliases (kept for backward compatibility).
|
// Legacy aliases (kept for backward compatibility).
|
||||||
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-opus-4-5");
|
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-opus-4-5");
|
||||||
|
|||||||
@@ -68,13 +68,9 @@ export const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
|
|||||||
"gemini-2.5-flash": "gemini-3-flash",
|
"gemini-2.5-flash": "gemini-3-flash",
|
||||||
|
|
||||||
// GLM (free + alpha)
|
// GLM (free + alpha)
|
||||||
glm: "glm-4.7-free",
|
glm: "glm-4.7",
|
||||||
"glm-free": "glm-4.7-free",
|
"glm-free": "glm-4.7",
|
||||||
"alpha-glm": "alpha-glm-4.7",
|
"alpha-glm": "alpha-glm-4.7",
|
||||||
|
|
||||||
// MiniMax
|
|
||||||
minimax: "minimax-m2.1-free",
|
|
||||||
"minimax-free": "minimax-m2.1-free",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,7 +130,7 @@ const MODEL_COSTS: Record<
|
|||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
"gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
|
"gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
|
||||||
"glm-4.7-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
"glm-4.7": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
|
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
|
||||||
"gpt-5.1-codex-max": {
|
"gpt-5.1-codex-max": {
|
||||||
input: 1.25,
|
input: 1.25,
|
||||||
@@ -142,7 +138,6 @@ const MODEL_COSTS: Record<
|
|||||||
cacheRead: 0.125,
|
cacheRead: 0.125,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
"minimax-m2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,10 +150,9 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|||||||
"alpha-glm-4.7": 204800,
|
"alpha-glm-4.7": 204800,
|
||||||
"gpt-5.1-codex-mini": 400000,
|
"gpt-5.1-codex-mini": 400000,
|
||||||
"gpt-5.1": 400000,
|
"gpt-5.1": 400000,
|
||||||
"glm-4.7-free": 204800,
|
"glm-4.7": 204800,
|
||||||
"gemini-3-flash": 1048576,
|
"gemini-3-flash": 1048576,
|
||||||
"gpt-5.1-codex-max": 400000,
|
"gpt-5.1-codex-max": 400000,
|
||||||
"minimax-m2.1-free": 204800,
|
|
||||||
"gpt-5.2": 400000,
|
"gpt-5.2": 400000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,10 +167,9 @@ const MODEL_MAX_TOKENS: Record<string, number> = {
|
|||||||
"alpha-glm-4.7": 131072,
|
"alpha-glm-4.7": 131072,
|
||||||
"gpt-5.1-codex-mini": 128000,
|
"gpt-5.1-codex-mini": 128000,
|
||||||
"gpt-5.1": 128000,
|
"gpt-5.1": 128000,
|
||||||
"glm-4.7-free": 131072,
|
"glm-4.7": 131072,
|
||||||
"gemini-3-flash": 65536,
|
"gemini-3-flash": 65536,
|
||||||
"gpt-5.1-codex-max": 128000,
|
"gpt-5.1-codex-max": 128000,
|
||||||
"minimax-m2.1-free": 131072,
|
|
||||||
"gpt-5.2": 128000,
|
"gpt-5.2": 128000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -211,10 +204,9 @@ const MODEL_NAMES: Record<string, string> = {
|
|||||||
"alpha-glm-4.7": "Alpha GLM-4.7",
|
"alpha-glm-4.7": "Alpha GLM-4.7",
|
||||||
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
||||||
"gpt-5.1": "GPT-5.1",
|
"gpt-5.1": "GPT-5.1",
|
||||||
"glm-4.7-free": "GLM-4.7",
|
"glm-4.7": "GLM-4.7",
|
||||||
"gemini-3-flash": "Gemini 3 Flash",
|
"gemini-3-flash": "Gemini 3 Flash",
|
||||||
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
||||||
"minimax-m2.1-free": "MiniMax M2.1",
|
|
||||||
"gpt-5.2": "GPT-5.2",
|
"gpt-5.2": "GPT-5.2",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,10 +232,9 @@ export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
|
|||||||
"alpha-glm-4.7",
|
"alpha-glm-4.7",
|
||||||
"gpt-5.1-codex-mini",
|
"gpt-5.1-codex-mini",
|
||||||
"gpt-5.1",
|
"gpt-5.1",
|
||||||
"glm-4.7-free",
|
"glm-4.7",
|
||||||
"gemini-3-flash",
|
"gemini-3-flash",
|
||||||
"gpt-5.1-codex-max",
|
"gpt-5.1-codex-max",
|
||||||
"minimax-m2.1-free",
|
|
||||||
"gpt-5.2",
|
"gpt-5.2",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
+1
@@ -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();
|
||||||
|
|||||||
+1
@@ -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", () => {
|
||||||
|
|||||||
+1
@@ -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";
|
||||||
|
|||||||
+25
-6
@@ -44,6 +44,7 @@ import {
|
|||||||
collectExplicitAllowlist,
|
collectExplicitAllowlist,
|
||||||
expandPolicyWithPluginGroups,
|
expandPolicyWithPluginGroups,
|
||||||
resolveToolProfilePolicy,
|
resolveToolProfilePolicy,
|
||||||
|
stripPluginOnlyAllowlist,
|
||||||
} from "./tool-policy.js";
|
} from "./tool-policy.js";
|
||||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||||
|
|
||||||
@@ -298,12 +299,30 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
tools,
|
tools,
|
||||||
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
||||||
});
|
});
|
||||||
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
|
const profilePolicyExpanded = expandPolicyWithPluginGroups(
|
||||||
const providerProfileExpanded = expandPolicyWithPluginGroups(providerProfilePolicy, pluginGroups);
|
stripPluginOnlyAllowlist(profilePolicy, pluginGroups),
|
||||||
const globalPolicyExpanded = expandPolicyWithPluginGroups(globalPolicy, pluginGroups);
|
pluginGroups,
|
||||||
const globalProviderExpanded = expandPolicyWithPluginGroups(globalProviderPolicy, pluginGroups);
|
);
|
||||||
const agentPolicyExpanded = expandPolicyWithPluginGroups(agentPolicy, pluginGroups);
|
const providerProfileExpanded = expandPolicyWithPluginGroups(
|
||||||
const agentProviderExpanded = expandPolicyWithPluginGroups(agentProviderPolicy, pluginGroups);
|
stripPluginOnlyAllowlist(providerProfilePolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
|
const globalPolicyExpanded = expandPolicyWithPluginGroups(
|
||||||
|
stripPluginOnlyAllowlist(globalPolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
|
const globalProviderExpanded = expandPolicyWithPluginGroups(
|
||||||
|
stripPluginOnlyAllowlist(globalProviderPolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
|
const agentPolicyExpanded = expandPolicyWithPluginGroups(
|
||||||
|
stripPluginOnlyAllowlist(agentPolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
|
const agentProviderExpanded = expandPolicyWithPluginGroups(
|
||||||
|
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
|
||||||
|
pluginGroups,
|
||||||
|
);
|
||||||
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
|
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
|
||||||
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
|
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { stripPluginOnlyAllowlist, type PluginToolGroups } from "./tool-policy.js";
|
||||||
|
|
||||||
|
const pluginGroups: PluginToolGroups = {
|
||||||
|
all: ["lobster", "workflow_tool"],
|
||||||
|
byPlugin: new Map([["lobster", ["lobster", "workflow_tool"]]]),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("stripPluginOnlyAllowlist", () => {
|
||||||
|
it("strips allowlist when it only targets plugin tools", () => {
|
||||||
|
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups);
|
||||||
|
expect(policy?.allow).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips allowlist when it only targets plugin groups", () => {
|
||||||
|
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups);
|
||||||
|
expect(policy?.allow).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps allowlist when it mixes plugin and core entries", () => {
|
||||||
|
const policy = stripPluginOnlyAllowlist({ allow: ["lobster", "read"] }, pluginGroups);
|
||||||
|
expect(policy?.allow).toEqual(["lobster", "read"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -178,6 +178,22 @@ export function expandPolicyWithPluginGroups(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stripPluginOnlyAllowlist(
|
||||||
|
policy: ToolPolicyLike | undefined,
|
||||||
|
groups: PluginToolGroups,
|
||||||
|
): ToolPolicyLike | undefined {
|
||||||
|
if (!policy?.allow || policy.allow.length === 0) return policy;
|
||||||
|
const normalized = normalizeToolList(policy.allow);
|
||||||
|
if (normalized.length === 0) return policy;
|
||||||
|
const pluginIds = new Set(groups.byPlugin.keys());
|
||||||
|
const pluginTools = new Set(groups.all);
|
||||||
|
const isPluginEntry = (entry: string) =>
|
||||||
|
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
|
||||||
|
const isPluginOnly = normalized.every((entry) => isPluginEntry(entry));
|
||||||
|
if (!isPluginOnly) return policy;
|
||||||
|
return { ...policy, allow: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
||||||
if (!profile) return undefined;
|
if (!profile) return undefined;
|
||||||
const resolved = TOOL_PROFILES[profile as ToolProfileId];
|
const resolved = TOOL_PROFILES[profile as ToolProfileId];
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ vi.mock("../../gateway/call.js", () => ({
|
|||||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../agent-scope.js", () => ({
|
||||||
|
resolveSessionAgentId: () => "agent-123",
|
||||||
|
}));
|
||||||
|
|
||||||
import { createCronTool } from "./cron-tool.js";
|
import { createCronTool } from "./cron-tool.js";
|
||||||
|
|
||||||
describe("cron tool", () => {
|
describe("cron tool", () => {
|
||||||
@@ -85,6 +89,23 @@ describe("cron tool", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not default agentId when job.agentId is null", async () => {
|
||||||
|
const tool = createCronTool({ agentSessionKey: "main" });
|
||||||
|
await tool.execute("call-null", {
|
||||||
|
action: "add",
|
||||||
|
job: {
|
||||||
|
name: "wake-up",
|
||||||
|
schedule: { atMs: 123 },
|
||||||
|
agentId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||||
|
params?: { agentId?: unknown };
|
||||||
|
};
|
||||||
|
expect(call?.params?.agentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
|
it("adds recent context for systemEvent reminders when contextMessages > 0", async () => {
|
||||||
callGatewayMock
|
callGatewayMock
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -188,4 +209,26 @@ describe("cron tool", () => {
|
|||||||
const text = cronCall.params?.payload?.text ?? "";
|
const text = cronCall.params?.payload?.text ?? "";
|
||||||
expect(text).not.toContain("Recent context:");
|
expect(text).not.toContain("Recent context:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves explicit agentId null on add", async () => {
|
||||||
|
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const tool = createCronTool({ agentSessionKey: "main" });
|
||||||
|
await tool.execute("call6", {
|
||||||
|
action: "add",
|
||||||
|
job: {
|
||||||
|
name: "reminder",
|
||||||
|
schedule: { atMs: 123 },
|
||||||
|
agentId: null,
|
||||||
|
payload: { kind: "systemEvent", text: "Reminder: the thing." },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||||
|
method?: string;
|
||||||
|
params?: { agentId?: string | null };
|
||||||
|
};
|
||||||
|
expect(call.method).toBe("cron.add");
|
||||||
|
expect(call.params?.agentId).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normal
|
|||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||||
|
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||||
@@ -158,6 +159,15 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
|
|||||||
throw new Error("job required");
|
throw new Error("job required");
|
||||||
}
|
}
|
||||||
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
||||||
|
if (job && typeof job === "object" && !("agentId" in job)) {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const agentId = opts?.agentSessionKey
|
||||||
|
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
|
||||||
|
: undefined;
|
||||||
|
if (agentId) {
|
||||||
|
(job as { agentId?: string }).agentId = agentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
const contextMessages =
|
const contextMessages =
|
||||||
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
|
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
|
||||||
? params.contextMessages
|
? params.contextMessages
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export async function handleDiscordGuildAction(
|
|||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
|
const accountId = readStringParam(params, "accountId");
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "memberInfo": {
|
case "memberInfo": {
|
||||||
if (!isActionEnabled("memberInfo")) {
|
if (!isActionEnabled("memberInfo")) {
|
||||||
@@ -50,7 +51,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const userId = readStringParam(params, "userId", {
|
const userId = readStringParam(params, "userId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const member = await fetchMemberInfoDiscord(guildId, userId);
|
const member = accountId
|
||||||
|
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
|
||||||
|
: await fetchMemberInfoDiscord(guildId, userId);
|
||||||
return jsonResult({ ok: true, member });
|
return jsonResult({ ok: true, member });
|
||||||
}
|
}
|
||||||
case "roleInfo": {
|
case "roleInfo": {
|
||||||
@@ -60,7 +63,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roles = await fetchRoleInfoDiscord(guildId);
|
const roles = accountId
|
||||||
|
? await fetchRoleInfoDiscord(guildId, { accountId })
|
||||||
|
: await fetchRoleInfoDiscord(guildId);
|
||||||
return jsonResult({ ok: true, roles });
|
return jsonResult({ ok: true, roles });
|
||||||
}
|
}
|
||||||
case "emojiList": {
|
case "emojiList": {
|
||||||
@@ -70,7 +75,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const emojis = await listGuildEmojisDiscord(guildId);
|
const emojis = accountId
|
||||||
|
? await listGuildEmojisDiscord(guildId, { accountId })
|
||||||
|
: await listGuildEmojisDiscord(guildId);
|
||||||
return jsonResult({ ok: true, emojis });
|
return jsonResult({ ok: true, emojis });
|
||||||
}
|
}
|
||||||
case "emojiUpload": {
|
case "emojiUpload": {
|
||||||
@@ -85,12 +92,22 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roleIds = readStringArrayParam(params, "roleIds");
|
const roleIds = readStringArrayParam(params, "roleIds");
|
||||||
const emoji = await uploadEmojiDiscord({
|
const emoji = accountId
|
||||||
guildId,
|
? await uploadEmojiDiscord(
|
||||||
name,
|
{
|
||||||
mediaUrl,
|
guildId,
|
||||||
roleIds: roleIds?.length ? roleIds : undefined,
|
name,
|
||||||
});
|
mediaUrl,
|
||||||
|
roleIds: roleIds?.length ? roleIds : undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await uploadEmojiDiscord({
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
mediaUrl,
|
||||||
|
roleIds: roleIds?.length ? roleIds : undefined,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, emoji });
|
return jsonResult({ ok: true, emoji });
|
||||||
}
|
}
|
||||||
case "stickerUpload": {
|
case "stickerUpload": {
|
||||||
@@ -108,13 +125,24 @@ export async function handleDiscordGuildAction(
|
|||||||
const mediaUrl = readStringParam(params, "mediaUrl", {
|
const mediaUrl = readStringParam(params, "mediaUrl", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const sticker = await uploadStickerDiscord({
|
const sticker = accountId
|
||||||
guildId,
|
? await uploadStickerDiscord(
|
||||||
name,
|
{
|
||||||
description,
|
guildId,
|
||||||
tags,
|
name,
|
||||||
mediaUrl,
|
description,
|
||||||
});
|
tags,
|
||||||
|
mediaUrl,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await uploadStickerDiscord({
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
mediaUrl,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, sticker });
|
return jsonResult({ ok: true, sticker });
|
||||||
}
|
}
|
||||||
case "roleAdd": {
|
case "roleAdd": {
|
||||||
@@ -128,7 +156,11 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roleId = readStringParam(params, "roleId", { required: true });
|
const roleId = readStringParam(params, "roleId", { required: true });
|
||||||
await addRoleDiscord({ guildId, userId, roleId });
|
if (accountId) {
|
||||||
|
await addRoleDiscord({ guildId, userId, roleId }, { accountId });
|
||||||
|
} else {
|
||||||
|
await addRoleDiscord({ guildId, userId, roleId });
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "roleRemove": {
|
case "roleRemove": {
|
||||||
@@ -142,7 +174,11 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const roleId = readStringParam(params, "roleId", { required: true });
|
const roleId = readStringParam(params, "roleId", { required: true });
|
||||||
await removeRoleDiscord({ guildId, userId, roleId });
|
if (accountId) {
|
||||||
|
await removeRoleDiscord({ guildId, userId, roleId }, { accountId });
|
||||||
|
} else {
|
||||||
|
await removeRoleDiscord({ guildId, userId, roleId });
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "channelInfo": {
|
case "channelInfo": {
|
||||||
@@ -152,7 +188,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const channel = await fetchChannelInfoDiscord(channelId);
|
const channel = accountId
|
||||||
|
? await fetchChannelInfoDiscord(channelId, { accountId })
|
||||||
|
: await fetchChannelInfoDiscord(channelId);
|
||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelList": {
|
case "channelList": {
|
||||||
@@ -162,7 +200,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const channels = await listGuildChannelsDiscord(guildId);
|
const channels = accountId
|
||||||
|
? await listGuildChannelsDiscord(guildId, { accountId })
|
||||||
|
: await listGuildChannelsDiscord(guildId);
|
||||||
return jsonResult({ ok: true, channels });
|
return jsonResult({ ok: true, channels });
|
||||||
}
|
}
|
||||||
case "voiceStatus": {
|
case "voiceStatus": {
|
||||||
@@ -175,7 +215,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const userId = readStringParam(params, "userId", {
|
const userId = readStringParam(params, "userId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const voice = await fetchVoiceStatusDiscord(guildId, userId);
|
const voice = accountId
|
||||||
|
? await fetchVoiceStatusDiscord(guildId, userId, { accountId })
|
||||||
|
: await fetchVoiceStatusDiscord(guildId, userId);
|
||||||
return jsonResult({ ok: true, voice });
|
return jsonResult({ ok: true, voice });
|
||||||
}
|
}
|
||||||
case "eventList": {
|
case "eventList": {
|
||||||
@@ -185,7 +227,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", {
|
const guildId = readStringParam(params, "guildId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const events = await listScheduledEventsDiscord(guildId);
|
const events = accountId
|
||||||
|
? await listScheduledEventsDiscord(guildId, { accountId })
|
||||||
|
: await listScheduledEventsDiscord(guildId);
|
||||||
return jsonResult({ ok: true, events });
|
return jsonResult({ ok: true, events });
|
||||||
}
|
}
|
||||||
case "eventCreate": {
|
case "eventCreate": {
|
||||||
@@ -215,7 +259,9 @@ export async function handleDiscordGuildAction(
|
|||||||
entity_metadata: entityType === 3 && location ? { location } : undefined,
|
entity_metadata: entityType === 3 && location ? { location } : undefined,
|
||||||
privacy_level: 2,
|
privacy_level: 2,
|
||||||
};
|
};
|
||||||
const event = await createScheduledEventDiscord(guildId, payload);
|
const event = accountId
|
||||||
|
? await createScheduledEventDiscord(guildId, payload, { accountId })
|
||||||
|
: await createScheduledEventDiscord(guildId, payload);
|
||||||
return jsonResult({ ok: true, event });
|
return jsonResult({ ok: true, event });
|
||||||
}
|
}
|
||||||
case "channelCreate": {
|
case "channelCreate": {
|
||||||
@@ -229,15 +275,28 @@ export async function handleDiscordGuildAction(
|
|||||||
const topic = readStringParam(params, "topic");
|
const topic = readStringParam(params, "topic");
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
const nsfw = params.nsfw as boolean | undefined;
|
const nsfw = params.nsfw as boolean | undefined;
|
||||||
const channel = await createChannelDiscord({
|
const channel = accountId
|
||||||
guildId,
|
? await createChannelDiscord(
|
||||||
name,
|
{
|
||||||
type: type ?? undefined,
|
guildId,
|
||||||
parentId: parentId ?? undefined,
|
name,
|
||||||
topic: topic ?? undefined,
|
type: type ?? undefined,
|
||||||
position: position ?? undefined,
|
parentId: parentId ?? undefined,
|
||||||
nsfw,
|
topic: topic ?? undefined,
|
||||||
});
|
position: position ?? undefined,
|
||||||
|
nsfw,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await createChannelDiscord({
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
type: type ?? undefined,
|
||||||
|
parentId: parentId ?? undefined,
|
||||||
|
topic: topic ?? undefined,
|
||||||
|
position: position ?? undefined,
|
||||||
|
nsfw,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelEdit": {
|
case "channelEdit": {
|
||||||
@@ -255,15 +314,28 @@ export async function handleDiscordGuildAction(
|
|||||||
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
|
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
|
||||||
integer: true,
|
integer: true,
|
||||||
});
|
});
|
||||||
const channel = await editChannelDiscord({
|
const channel = accountId
|
||||||
channelId,
|
? await editChannelDiscord(
|
||||||
name: name ?? undefined,
|
{
|
||||||
topic: topic ?? undefined,
|
channelId,
|
||||||
position: position ?? undefined,
|
name: name ?? undefined,
|
||||||
parentId,
|
topic: topic ?? undefined,
|
||||||
nsfw,
|
position: position ?? undefined,
|
||||||
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
parentId,
|
||||||
});
|
nsfw,
|
||||||
|
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await editChannelDiscord({
|
||||||
|
channelId,
|
||||||
|
name: name ?? undefined,
|
||||||
|
topic: topic ?? undefined,
|
||||||
|
position: position ?? undefined,
|
||||||
|
parentId,
|
||||||
|
nsfw,
|
||||||
|
rateLimitPerUser: rateLimitPerUser ?? undefined,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelDelete": {
|
case "channelDelete": {
|
||||||
@@ -273,7 +345,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const result = await deleteChannelDiscord(channelId);
|
const result = accountId
|
||||||
|
? await deleteChannelDiscord(channelId, { accountId })
|
||||||
|
: await deleteChannelDiscord(channelId);
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
}
|
}
|
||||||
case "channelMove": {
|
case "channelMove": {
|
||||||
@@ -286,12 +360,24 @@ export async function handleDiscordGuildAction(
|
|||||||
});
|
});
|
||||||
const parentId = readParentIdParam(params);
|
const parentId = readParentIdParam(params);
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
await moveChannelDiscord({
|
if (accountId) {
|
||||||
guildId,
|
await moveChannelDiscord(
|
||||||
channelId,
|
{
|
||||||
parentId,
|
guildId,
|
||||||
position: position ?? undefined,
|
channelId,
|
||||||
});
|
parentId,
|
||||||
|
position: position ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await moveChannelDiscord({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
parentId,
|
||||||
|
position: position ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "categoryCreate": {
|
case "categoryCreate": {
|
||||||
@@ -301,12 +387,22 @@ export async function handleDiscordGuildAction(
|
|||||||
const guildId = readStringParam(params, "guildId", { required: true });
|
const guildId = readStringParam(params, "guildId", { required: true });
|
||||||
const name = readStringParam(params, "name", { required: true });
|
const name = readStringParam(params, "name", { required: true });
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
const channel = await createChannelDiscord({
|
const channel = accountId
|
||||||
guildId,
|
? await createChannelDiscord(
|
||||||
name,
|
{
|
||||||
type: 4,
|
guildId,
|
||||||
position: position ?? undefined,
|
name,
|
||||||
});
|
type: 4,
|
||||||
|
position: position ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await createChannelDiscord({
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
type: 4,
|
||||||
|
position: position ?? undefined,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, category: channel });
|
return jsonResult({ ok: true, category: channel });
|
||||||
}
|
}
|
||||||
case "categoryEdit": {
|
case "categoryEdit": {
|
||||||
@@ -318,11 +414,20 @@ export async function handleDiscordGuildAction(
|
|||||||
});
|
});
|
||||||
const name = readStringParam(params, "name");
|
const name = readStringParam(params, "name");
|
||||||
const position = readNumberParam(params, "position", { integer: true });
|
const position = readNumberParam(params, "position", { integer: true });
|
||||||
const channel = await editChannelDiscord({
|
const channel = accountId
|
||||||
channelId: categoryId,
|
? await editChannelDiscord(
|
||||||
name: name ?? undefined,
|
{
|
||||||
position: position ?? undefined,
|
channelId: categoryId,
|
||||||
});
|
name: name ?? undefined,
|
||||||
|
position: position ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await editChannelDiscord({
|
||||||
|
channelId: categoryId,
|
||||||
|
name: name ?? undefined,
|
||||||
|
position: position ?? undefined,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, category: channel });
|
return jsonResult({ ok: true, category: channel });
|
||||||
}
|
}
|
||||||
case "categoryDelete": {
|
case "categoryDelete": {
|
||||||
@@ -332,7 +437,9 @@ export async function handleDiscordGuildAction(
|
|||||||
const categoryId = readStringParam(params, "categoryId", {
|
const categoryId = readStringParam(params, "categoryId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const result = await deleteChannelDiscord(categoryId);
|
const result = accountId
|
||||||
|
? await deleteChannelDiscord(categoryId, { accountId })
|
||||||
|
: await deleteChannelDiscord(categoryId);
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
}
|
}
|
||||||
case "channelPermissionSet": {
|
case "channelPermissionSet": {
|
||||||
@@ -349,13 +456,26 @@ export async function handleDiscordGuildAction(
|
|||||||
const targetType = targetTypeRaw === "member" ? 1 : 0;
|
const targetType = targetTypeRaw === "member" ? 1 : 0;
|
||||||
const allow = readStringParam(params, "allow");
|
const allow = readStringParam(params, "allow");
|
||||||
const deny = readStringParam(params, "deny");
|
const deny = readStringParam(params, "deny");
|
||||||
await setChannelPermissionDiscord({
|
if (accountId) {
|
||||||
channelId,
|
await setChannelPermissionDiscord(
|
||||||
targetId,
|
{
|
||||||
targetType,
|
channelId,
|
||||||
allow: allow ?? undefined,
|
targetId,
|
||||||
deny: deny ?? undefined,
|
targetType,
|
||||||
});
|
allow: allow ?? undefined,
|
||||||
|
deny: deny ?? undefined,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await setChannelPermissionDiscord({
|
||||||
|
channelId,
|
||||||
|
targetId,
|
||||||
|
targetType,
|
||||||
|
allow: allow ?? undefined,
|
||||||
|
deny: deny ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "channelPermissionRemove": {
|
case "channelPermissionRemove": {
|
||||||
@@ -366,7 +486,11 @@ export async function handleDiscordGuildAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const targetId = readStringParam(params, "targetId", { required: true });
|
const targetId = readStringParam(params, "targetId", { required: true });
|
||||||
await removeChannelPermissionDiscord(channelId, targetId);
|
if (accountId) {
|
||||||
|
await removeChannelPermissionDiscord(channelId, targetId, { accountId });
|
||||||
|
} else {
|
||||||
|
await removeChannelPermissionDiscord(channelId, targetId);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
required: true,
|
required: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const accountId = readStringParam(params, "accountId");
|
||||||
const normalizeMessage = (message: unknown) => {
|
const normalizeMessage = (message: unknown) => {
|
||||||
if (!message || typeof message !== "object") return message;
|
if (!message || typeof message !== "object") return message;
|
||||||
return withNormalizedTimestamp(
|
return withNormalizedTimestamp(
|
||||||
@@ -78,14 +79,24 @@ export async function handleDiscordMessagingAction(
|
|||||||
removeErrorMessage: "Emoji is required to remove a Discord reaction.",
|
removeErrorMessage: "Emoji is required to remove a Discord reaction.",
|
||||||
});
|
});
|
||||||
if (remove) {
|
if (remove) {
|
||||||
await removeReactionDiscord(channelId, messageId, emoji);
|
if (accountId) {
|
||||||
|
await removeReactionDiscord(channelId, messageId, emoji, { accountId });
|
||||||
|
} else {
|
||||||
|
await removeReactionDiscord(channelId, messageId, emoji);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true, removed: emoji });
|
return jsonResult({ ok: true, removed: emoji });
|
||||||
}
|
}
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
const removed = await removeOwnReactionsDiscord(channelId, messageId);
|
const removed = accountId
|
||||||
|
? await removeOwnReactionsDiscord(channelId, messageId, { accountId })
|
||||||
|
: await removeOwnReactionsDiscord(channelId, messageId);
|
||||||
return jsonResult({ ok: true, removed: removed.removed });
|
return jsonResult({ ok: true, removed: removed.removed });
|
||||||
}
|
}
|
||||||
await reactMessageDiscord(channelId, messageId, emoji);
|
if (accountId) {
|
||||||
|
await reactMessageDiscord(channelId, messageId, emoji, { accountId });
|
||||||
|
} else {
|
||||||
|
await reactMessageDiscord(channelId, messageId, emoji);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true, added: emoji });
|
return jsonResult({ ok: true, added: emoji });
|
||||||
}
|
}
|
||||||
case "reactions": {
|
case "reactions": {
|
||||||
@@ -100,6 +111,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
const limit =
|
const limit =
|
||||||
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
||||||
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
return jsonResult({ ok: true, reactions });
|
return jsonResult({ ok: true, reactions });
|
||||||
@@ -114,7 +126,10 @@ export async function handleDiscordMessagingAction(
|
|||||||
required: true,
|
required: true,
|
||||||
label: "stickerIds",
|
label: "stickerIds",
|
||||||
});
|
});
|
||||||
await sendStickerDiscord(to, stickerIds, { content });
|
await sendStickerDiscord(to, stickerIds, {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
|
content,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "poll": {
|
case "poll": {
|
||||||
@@ -140,7 +155,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
await sendPollDiscord(
|
await sendPollDiscord(
|
||||||
to,
|
to,
|
||||||
{ question, options: answers, maxSelections, durationHours },
|
{ question, options: answers, maxSelections, durationHours },
|
||||||
{ content },
|
{ ...(accountId ? { accountId } : {}), content },
|
||||||
);
|
);
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -149,7 +164,9 @@ export async function handleDiscordMessagingAction(
|
|||||||
throw new Error("Discord permissions are disabled.");
|
throw new Error("Discord permissions are disabled.");
|
||||||
}
|
}
|
||||||
const channelId = resolveChannelId();
|
const channelId = resolveChannelId();
|
||||||
const permissions = await fetchChannelPermissionsDiscord(channelId);
|
const permissions = accountId
|
||||||
|
? await fetchChannelPermissionsDiscord(channelId, { accountId })
|
||||||
|
: await fetchChannelPermissionsDiscord(channelId);
|
||||||
return jsonResult({ ok: true, permissions });
|
return jsonResult({ ok: true, permissions });
|
||||||
}
|
}
|
||||||
case "fetchMessage": {
|
case "fetchMessage": {
|
||||||
@@ -171,7 +188,9 @@ export async function handleDiscordMessagingAction(
|
|||||||
"Discord message fetch requires guildId, channelId, and messageId (or a valid messageLink).",
|
"Discord message fetch requires guildId, channelId, and messageId (or a valid messageLink).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const message = await fetchMessageDiscord(channelId, messageId);
|
const message = accountId
|
||||||
|
? await fetchMessageDiscord(channelId, messageId, { accountId })
|
||||||
|
: await fetchMessageDiscord(channelId, messageId);
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
ok: true,
|
ok: true,
|
||||||
message: normalizeMessage(message),
|
message: normalizeMessage(message),
|
||||||
@@ -185,7 +204,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
throw new Error("Discord message reads are disabled.");
|
throw new Error("Discord message reads are disabled.");
|
||||||
}
|
}
|
||||||
const channelId = resolveChannelId();
|
const channelId = resolveChannelId();
|
||||||
const messages = await readMessagesDiscord(channelId, {
|
const query = {
|
||||||
limit:
|
limit:
|
||||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||||
? params.limit
|
? params.limit
|
||||||
@@ -193,7 +212,10 @@ export async function handleDiscordMessagingAction(
|
|||||||
before: readStringParam(params, "before"),
|
before: readStringParam(params, "before"),
|
||||||
after: readStringParam(params, "after"),
|
after: readStringParam(params, "after"),
|
||||||
around: readStringParam(params, "around"),
|
around: readStringParam(params, "around"),
|
||||||
});
|
};
|
||||||
|
const messages = accountId
|
||||||
|
? await readMessagesDiscord(channelId, query, { accountId })
|
||||||
|
: await readMessagesDiscord(channelId, query);
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
ok: true,
|
ok: true,
|
||||||
messages: messages.map((message) => normalizeMessage(message)),
|
messages: messages.map((message) => normalizeMessage(message)),
|
||||||
@@ -212,6 +234,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
const embeds =
|
const embeds =
|
||||||
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
|
Array.isArray(params.embeds) && params.embeds.length > 0 ? params.embeds : undefined;
|
||||||
const result = await sendMessageDiscord(to, content, {
|
const result = await sendMessageDiscord(to, content, {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyTo,
|
replyTo,
|
||||||
embeds,
|
embeds,
|
||||||
@@ -229,9 +252,9 @@ export async function handleDiscordMessagingAction(
|
|||||||
const content = readStringParam(params, "content", {
|
const content = readStringParam(params, "content", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const message = await editMessageDiscord(channelId, messageId, {
|
const message = accountId
|
||||||
content,
|
? await editMessageDiscord(channelId, messageId, { content }, { accountId })
|
||||||
});
|
: await editMessageDiscord(channelId, messageId, { content });
|
||||||
return jsonResult({ ok: true, message });
|
return jsonResult({ ok: true, message });
|
||||||
}
|
}
|
||||||
case "deleteMessage": {
|
case "deleteMessage": {
|
||||||
@@ -242,7 +265,11 @@ export async function handleDiscordMessagingAction(
|
|||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readStringParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
await deleteMessageDiscord(channelId, messageId);
|
if (accountId) {
|
||||||
|
await deleteMessageDiscord(channelId, messageId, { accountId });
|
||||||
|
} else {
|
||||||
|
await deleteMessageDiscord(channelId, messageId);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "threadCreate": {
|
case "threadCreate": {
|
||||||
@@ -257,11 +284,13 @@ export async function handleDiscordMessagingAction(
|
|||||||
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
|
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
|
||||||
? autoArchiveMinutesRaw
|
? autoArchiveMinutesRaw
|
||||||
: undefined;
|
: undefined;
|
||||||
const thread = await createThreadDiscord(channelId, {
|
const thread = accountId
|
||||||
name,
|
? await createThreadDiscord(
|
||||||
messageId,
|
channelId,
|
||||||
autoArchiveMinutes,
|
{ name, messageId, autoArchiveMinutes },
|
||||||
});
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await createThreadDiscord(channelId, { name, messageId, autoArchiveMinutes });
|
||||||
return jsonResult({ ok: true, thread });
|
return jsonResult({ ok: true, thread });
|
||||||
}
|
}
|
||||||
case "threadList": {
|
case "threadList": {
|
||||||
@@ -279,13 +308,24 @@ export async function handleDiscordMessagingAction(
|
|||||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||||
? params.limit
|
? params.limit
|
||||||
: undefined;
|
: undefined;
|
||||||
const threads = await listThreadsDiscord({
|
const threads = accountId
|
||||||
guildId,
|
? await listThreadsDiscord(
|
||||||
channelId,
|
{
|
||||||
includeArchived,
|
guildId,
|
||||||
before,
|
channelId,
|
||||||
limit,
|
includeArchived,
|
||||||
});
|
before,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await listThreadsDiscord({
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
includeArchived,
|
||||||
|
before,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, threads });
|
return jsonResult({ ok: true, threads });
|
||||||
}
|
}
|
||||||
case "threadReply": {
|
case "threadReply": {
|
||||||
@@ -299,6 +339,7 @@ export async function handleDiscordMessagingAction(
|
|||||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||||
const replyTo = readStringParam(params, "replyTo");
|
const replyTo = readStringParam(params, "replyTo");
|
||||||
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
|
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyTo,
|
replyTo,
|
||||||
});
|
});
|
||||||
@@ -312,7 +353,11 @@ export async function handleDiscordMessagingAction(
|
|||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readStringParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
await pinMessageDiscord(channelId, messageId);
|
if (accountId) {
|
||||||
|
await pinMessageDiscord(channelId, messageId, { accountId });
|
||||||
|
} else {
|
||||||
|
await pinMessageDiscord(channelId, messageId);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "unpinMessage": {
|
case "unpinMessage": {
|
||||||
@@ -323,7 +368,11 @@ export async function handleDiscordMessagingAction(
|
|||||||
const messageId = readStringParam(params, "messageId", {
|
const messageId = readStringParam(params, "messageId", {
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
await unpinMessageDiscord(channelId, messageId);
|
if (accountId) {
|
||||||
|
await unpinMessageDiscord(channelId, messageId, { accountId });
|
||||||
|
} else {
|
||||||
|
await unpinMessageDiscord(channelId, messageId);
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "listPins": {
|
case "listPins": {
|
||||||
@@ -331,7 +380,9 @@ export async function handleDiscordMessagingAction(
|
|||||||
throw new Error("Discord pins are disabled.");
|
throw new Error("Discord pins are disabled.");
|
||||||
}
|
}
|
||||||
const channelId = resolveChannelId();
|
const channelId = resolveChannelId();
|
||||||
const pins = await listPinsDiscord(channelId);
|
const pins = accountId
|
||||||
|
? await listPinsDiscord(channelId, { accountId })
|
||||||
|
: await listPinsDiscord(channelId);
|
||||||
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
|
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
|
||||||
}
|
}
|
||||||
case "searchMessages": {
|
case "searchMessages": {
|
||||||
@@ -354,13 +405,24 @@ export async function handleDiscordMessagingAction(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
|
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
|
||||||
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
|
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
|
||||||
const results = await searchMessagesDiscord({
|
const results = accountId
|
||||||
guildId,
|
? await searchMessagesDiscord(
|
||||||
content,
|
{
|
||||||
channelIds: channelIdList.length ? channelIdList : undefined,
|
guildId,
|
||||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
content,
|
||||||
limit,
|
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||||
});
|
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await searchMessagesDiscord({
|
||||||
|
guildId,
|
||||||
|
content,
|
||||||
|
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||||
|
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
if (!results || typeof results !== "object") {
|
if (!results || typeof results !== "object") {
|
||||||
return jsonResult({ ok: true, results });
|
return jsonResult({ ok: true, results });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export async function handleDiscordModerationAction(
|
|||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||||
): Promise<AgentToolResult<unknown>> {
|
): Promise<AgentToolResult<unknown>> {
|
||||||
|
const accountId = readStringParam(params, "accountId");
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "timeout": {
|
case "timeout": {
|
||||||
if (!isActionEnabled("moderation", false)) {
|
if (!isActionEnabled("moderation", false)) {
|
||||||
@@ -25,13 +26,24 @@ export async function handleDiscordModerationAction(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const until = readStringParam(params, "until");
|
const until = readStringParam(params, "until");
|
||||||
const reason = readStringParam(params, "reason");
|
const reason = readStringParam(params, "reason");
|
||||||
const member = await timeoutMemberDiscord({
|
const member = accountId
|
||||||
guildId,
|
? await timeoutMemberDiscord(
|
||||||
userId,
|
{
|
||||||
durationMinutes,
|
guildId,
|
||||||
until,
|
userId,
|
||||||
reason,
|
durationMinutes,
|
||||||
});
|
until,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
)
|
||||||
|
: await timeoutMemberDiscord({
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
durationMinutes,
|
||||||
|
until,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
return jsonResult({ ok: true, member });
|
return jsonResult({ ok: true, member });
|
||||||
}
|
}
|
||||||
case "kick": {
|
case "kick": {
|
||||||
@@ -45,7 +57,11 @@ export async function handleDiscordModerationAction(
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
const reason = readStringParam(params, "reason");
|
const reason = readStringParam(params, "reason");
|
||||||
await kickMemberDiscord({ guildId, userId, reason });
|
if (accountId) {
|
||||||
|
await kickMemberDiscord({ guildId, userId, reason }, { accountId });
|
||||||
|
} else {
|
||||||
|
await kickMemberDiscord({ guildId, userId, reason });
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "ban": {
|
case "ban": {
|
||||||
@@ -63,12 +79,24 @@ export async function handleDiscordModerationAction(
|
|||||||
typeof params.deleteMessageDays === "number" && Number.isFinite(params.deleteMessageDays)
|
typeof params.deleteMessageDays === "number" && Number.isFinite(params.deleteMessageDays)
|
||||||
? params.deleteMessageDays
|
? params.deleteMessageDays
|
||||||
: undefined;
|
: undefined;
|
||||||
await banMemberDiscord({
|
if (accountId) {
|
||||||
guildId,
|
await banMemberDiscord(
|
||||||
userId,
|
{
|
||||||
reason,
|
guildId,
|
||||||
deleteMessageDays,
|
userId,
|
||||||
});
|
reason,
|
||||||
|
deleteMessageDays,
|
||||||
|
},
|
||||||
|
{ accountId },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await banMemberDiscord({
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
reason,
|
||||||
|
deleteMessageDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import type { DiscordActionConfig } from "../../config/config.js";
|
import type { DiscordActionConfig } from "../../config/config.js";
|
||||||
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
||||||
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
||||||
|
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
|
||||||
|
|
||||||
const createChannelDiscord = vi.fn(async () => ({
|
const createChannelDiscord = vi.fn(async () => ({
|
||||||
id: "new-channel",
|
id: "new-channel",
|
||||||
@@ -20,6 +21,7 @@ const editMessageDiscord = vi.fn(async () => ({}));
|
|||||||
const fetchMessageDiscord = vi.fn(async () => ({}));
|
const fetchMessageDiscord = vi.fn(async () => ({}));
|
||||||
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
|
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
|
||||||
const fetchReactionsDiscord = vi.fn(async () => ({}));
|
const fetchReactionsDiscord = vi.fn(async () => ({}));
|
||||||
|
const listGuildChannelsDiscord = vi.fn(async () => []);
|
||||||
const listPinsDiscord = vi.fn(async () => ({}));
|
const listPinsDiscord = vi.fn(async () => ({}));
|
||||||
const listThreadsDiscord = vi.fn(async () => ({}));
|
const listThreadsDiscord = vi.fn(async () => ({}));
|
||||||
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
|
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
|
||||||
@@ -35,8 +37,12 @@ const sendPollDiscord = vi.fn(async () => ({}));
|
|||||||
const sendStickerDiscord = vi.fn(async () => ({}));
|
const sendStickerDiscord = vi.fn(async () => ({}));
|
||||||
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
|
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
|
||||||
const unpinMessageDiscord = vi.fn(async () => ({}));
|
const unpinMessageDiscord = vi.fn(async () => ({}));
|
||||||
|
const timeoutMemberDiscord = vi.fn(async () => ({}));
|
||||||
|
const kickMemberDiscord = vi.fn(async () => ({}));
|
||||||
|
const banMemberDiscord = vi.fn(async () => ({}));
|
||||||
|
|
||||||
vi.mock("../../discord/send.js", () => ({
|
vi.mock("../../discord/send.js", () => ({
|
||||||
|
banMemberDiscord: (...args: unknown[]) => banMemberDiscord(...args),
|
||||||
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
|
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
|
||||||
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
|
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
|
||||||
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
|
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
|
||||||
@@ -46,6 +52,8 @@ vi.mock("../../discord/send.js", () => ({
|
|||||||
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
|
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
|
||||||
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
|
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
|
||||||
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
|
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
|
||||||
|
kickMemberDiscord: (...args: unknown[]) => kickMemberDiscord(...args),
|
||||||
|
listGuildChannelsDiscord: (...args: unknown[]) => listGuildChannelsDiscord(...args),
|
||||||
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
|
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
|
||||||
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
|
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
|
||||||
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
|
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
|
||||||
@@ -60,12 +68,15 @@ vi.mock("../../discord/send.js", () => ({
|
|||||||
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
|
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
|
||||||
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
|
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
|
||||||
setChannelPermissionDiscord: (...args: unknown[]) => setChannelPermissionDiscord(...args),
|
setChannelPermissionDiscord: (...args: unknown[]) => setChannelPermissionDiscord(...args),
|
||||||
|
timeoutMemberDiscord: (...args: unknown[]) => timeoutMemberDiscord(...args),
|
||||||
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
|
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const enableAllActions = () => true;
|
const enableAllActions = () => true;
|
||||||
|
|
||||||
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
|
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
|
||||||
|
const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo";
|
||||||
|
const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation";
|
||||||
|
|
||||||
describe("handleDiscordMessagingAction", () => {
|
describe("handleDiscordMessagingAction", () => {
|
||||||
it("adds reactions", async () => {
|
it("adds reactions", async () => {
|
||||||
@@ -81,6 +92,20 @@ describe("handleDiscordMessagingAction", () => {
|
|||||||
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅");
|
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards accountId for reactions", async () => {
|
||||||
|
await handleDiscordMessagingAction(
|
||||||
|
"react",
|
||||||
|
{
|
||||||
|
channelId: "C1",
|
||||||
|
messageId: "M1",
|
||||||
|
emoji: "✅",
|
||||||
|
accountId: "ops",
|
||||||
|
},
|
||||||
|
enableAllActions,
|
||||||
|
);
|
||||||
|
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", { accountId: "ops" });
|
||||||
|
});
|
||||||
|
|
||||||
it("removes reactions on empty emoji", async () => {
|
it("removes reactions on empty emoji", async () => {
|
||||||
await handleDiscordMessagingAction(
|
await handleDiscordMessagingAction(
|
||||||
"react",
|
"react",
|
||||||
@@ -245,6 +270,15 @@ describe("handleDiscordGuildAction - channel management", () => {
|
|||||||
).rejects.toThrow(/Discord channel management is disabled/);
|
).rejects.toThrow(/Discord channel management is disabled/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards accountId for channelList", async () => {
|
||||||
|
await handleDiscordGuildAction(
|
||||||
|
"channelList",
|
||||||
|
{ guildId: "G1", accountId: "ops" },
|
||||||
|
channelInfoEnabled,
|
||||||
|
);
|
||||||
|
expect(listGuildChannelsDiscord).toHaveBeenCalledWith("G1", { accountId: "ops" });
|
||||||
|
});
|
||||||
|
|
||||||
it("edits a channel", async () => {
|
it("edits a channel", async () => {
|
||||||
await handleDiscordGuildAction(
|
await handleDiscordGuildAction(
|
||||||
"channelEdit",
|
"channelEdit",
|
||||||
@@ -448,3 +482,26 @@ describe("handleDiscordGuildAction - channel management", () => {
|
|||||||
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
|
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("handleDiscordModerationAction", () => {
|
||||||
|
it("forwards accountId for timeout", async () => {
|
||||||
|
await handleDiscordModerationAction(
|
||||||
|
"timeout",
|
||||||
|
{
|
||||||
|
guildId: "G1",
|
||||||
|
userId: "U1",
|
||||||
|
durationMinutes: 5,
|
||||||
|
accountId: "ops",
|
||||||
|
},
|
||||||
|
moderationEnabled,
|
||||||
|
);
|
||||||
|
expect(timeoutMemberDiscord).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
guildId: "G1",
|
||||||
|
userId: "U1",
|
||||||
|
durationMinutes: 5,
|
||||||
|
}),
|
||||||
|
{ accountId: "ops" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -342,6 +342,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
}) as ChannelMessageActionName;
|
}) as ChannelMessageActionName;
|
||||||
|
|
||||||
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
|
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
|
||||||
|
if (accountId) {
|
||||||
|
params.accountId = accountId;
|
||||||
|
}
|
||||||
|
|
||||||
const gateway = {
|
const gateway = {
|
||||||
url: readStringParam(params, "gatewayUrl", { trim: false }),
|
url: readStringParam(params, "gatewayUrl", { trim: false }),
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user