Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bba80a4f5 | ||
|
|
05195a390c |
No files matched your search
+1
-6
@@ -4,15 +4,10 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
## 2026.1.23
|
## 2026.1.23
|
||||||
|
|
||||||
### Changes
|
|
||||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
|
||||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
|
||||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||||
- Gateway/Linux: include user + env-configured bin dirs in systemd PATH and align service audit checks. (#1512) Thanks @robbyczgw-cla.
|
|
||||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||||
|
- Daemon: prefer symlinked CLI paths to keep service configs stable. (#1505) Thanks @odysseus0.
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)"
|
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)"
|
||||||
read_when:
|
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 --no-restart
|
clawdbot update --restart
|
||||||
clawdbot update --json
|
clawdbot update --json
|
||||||
clawdbot --update
|
clawdbot --update
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--no-restart`: skip restarting the Gateway service after a successful update.
|
- `--restart`: restart the Gateway service after a successful update.
|
||||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
- `--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,8 +52,7 @@ 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 (default is to restart). If you select `dev` without a git checkout, it
|
after updating. If you select `dev` without a git checkout, it offers to create one.
|
||||||
offers to create one.
|
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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.
|
||||||
@@ -60,30 +59,7 @@ 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, with Markdown table conversion applied before
|
their own formatting rules.
|
||||||
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 (or use `clawdbot update`, which restarts) → verify.
|
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
|
||||||
|
|
||||||
## Recommended: re-run the website installer (upgrade in place)
|
## 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
|
clawdbot update --restart
|
||||||
```
|
```
|
||||||
|
|
||||||
It runs a safe-ish update flow:
|
It runs a safe-ish update flow:
|
||||||
@@ -89,7 +89,6 @@ It runs a safe-ish update flow:
|
|||||||
- Switches to the selected channel (tag or branch).
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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()]);
|
||||||
@@ -26,7 +25,6 @@ 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,8 +99,6 @@ 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"],
|
||||||
@@ -222,12 +220,6 @@ 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;
|
||||||
|
|
||||||
@@ -514,7 +506,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -562,7 +554,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||||
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||||
@@ -609,7 +601,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
|
||||||
// Wait for async processing
|
// Wait for async processing
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
@@ -648,7 +640,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
@@ -689,7 +681,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
@@ -732,7 +724,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
||||||
// Should not send pairing reply since created=false
|
// Should not send pairing reply since created=false
|
||||||
@@ -773,7 +765,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -810,7 +802,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -850,7 +842,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -888,7 +880,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -927,7 +919,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -966,7 +958,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1007,7 +999,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1048,7 +1040,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1086,7 +1078,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1129,7 +1121,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1175,7 +1167,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1221,7 +1213,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const originalRes = createMockResponse();
|
const originalRes = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
|
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Only assert the reply message behavior below.
|
// Only assert the reply message behavior below.
|
||||||
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||||
@@ -1245,7 +1237,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const replyRes = createMockResponse();
|
const replyRes = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
|
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1291,7 +1283,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1339,7 +1331,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -1392,7 +1384,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Should process even without mention because it's an authorized control command
|
// Should process even without mention because it's an authorized control command
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
@@ -1435,7 +1427,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1478,7 +1470,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(markBlueBubblesChatRead).toHaveBeenCalled();
|
expect(markBlueBubblesChatRead).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1519,7 +1511,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
|
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1562,7 +1554,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Should call typing start when reply flow triggers it.
|
// Should call typing start when reply flow triggers it.
|
||||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||||
@@ -1612,7 +1604,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
@@ -1657,7 +1649,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
@@ -1705,7 +1697,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
|
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
@@ -1750,7 +1742,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("reaction added"),
|
expect.stringContaining("reaction added"),
|
||||||
@@ -1790,7 +1782,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("reaction removed"),
|
expect.stringContaining("reaction removed"),
|
||||||
@@ -1830,7 +1822,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1868,7 +1860,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("👍"),
|
expect.stringContaining("👍"),
|
||||||
@@ -1909,7 +1901,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
@@ -1949,7 +1941,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
// 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");
|
||||||
@@ -2001,7 +1993,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await flushAsync();
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1662,15 +1662,9 @@ 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 ? text : undefined;
|
const caption = first ? payload.text : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
const result = await sendBlueBubblesMedia({
|
const result = await sendBlueBubblesMedia({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -1692,14 +1686,8 @@ 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 tableMode = core.channel.text.resolveMarkdownTableMode({
|
const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit);
|
||||||
cfg: config,
|
if (!chunks.length && payload.text) chunks.push(payload.text);
|
||||||
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,4 +1,3 @@
|
|||||||
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()]);
|
||||||
@@ -36,7 +35,6 @@ 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,11 +548,6 @@ 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)
|
||||||
@@ -567,8 +562,6 @@ 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 { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { 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,17 +12,8 @@ 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);
|
||||||
@@ -42,8 +33,6 @@ 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
|
||||||
@@ -54,14 +43,13 @@ 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(text, chunkLimit)) {
|
for (const chunk of core.channel.text.chunkMarkdownText(reply.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;
|
||||||
@@ -72,14 +60,13 @@ export async function deliverMatrixReplies(params: {
|
|||||||
|
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const caption = first ? text : "";
|
const caption = first ? (reply.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,8 +43,6 @@ 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,18 +50,9 @@ 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(convertedMessage, chunkLimit);
|
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, 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,7 +87,6 @@ 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,7 +4,6 @@ import {
|
|||||||
BlockStreamingCoalesceSchema,
|
BlockStreamingCoalesceSchema,
|
||||||
DmPolicySchema,
|
DmPolicySchema,
|
||||||
GroupPolicySchema,
|
GroupPolicySchema,
|
||||||
MarkdownConfigSchema,
|
|
||||||
requireOpenAllowFrom,
|
requireOpenAllowFrom,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
@@ -12,7 +11,6 @@ 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,11 +707,6 @@ 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),
|
||||||
@@ -725,7 +720,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 = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
const text = payload.text ?? "";
|
||||||
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,15 +181,6 @@ export async function sendMessageMattermost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message) {
|
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg,
|
|
||||||
channel: "mattermost",
|
|
||||||
accountId: account.accountId,
|
|
||||||
});
|
|
||||||
message = core.channel.text.convertMarkdownTables(message, tableMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
if (!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}`);
|
||||||
@@ -214,4 +205,4 @@ export async function sendMessageMattermost(
|
|||||||
messageId: post.id ?? "unknown",
|
messageId: post.id ?? "unknown",
|
||||||
channelId,
|
channelId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,6 @@ const runtimeStub = {
|
|||||||
}
|
}
|
||||||
return chunks;
|
return chunks;
|
||||||
},
|
},
|
||||||
resolveMarkdownTableMode: () => "code",
|
|
||||||
convertMarkdownTables: (text: string) => text,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as PluginRuntime;
|
} as unknown as PluginRuntime;
|
||||||
@@ -36,7 +34,6 @@ 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([]);
|
||||||
});
|
});
|
||||||
@@ -44,7 +41,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, tableMode: "code" },
|
{ textChunkLimit: 4000 },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([]);
|
expect(messages).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -52,7 +49,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, tableMode: "code" },
|
{ textChunkLimit: 4000 },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
||||||
});
|
});
|
||||||
@@ -60,7 +57,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", tableMode: "code" },
|
{ textChunkLimit: 4000, mediaMode: "inline" },
|
||||||
);
|
);
|
||||||
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
||||||
});
|
});
|
||||||
@@ -69,7 +66,6 @@ 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,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
isSilentReplyText,
|
isSilentReplyText,
|
||||||
loadWebMedia,
|
loadWebMedia,
|
||||||
type MarkdownTableMode,
|
|
||||||
type MSTeamsReplyStyle,
|
type MSTeamsReplyStyle,
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
SILENT_REPLY_TOKEN,
|
SILENT_REPLY_TOKEN,
|
||||||
@@ -62,7 +61,6 @@ export type MSTeamsReplyRenderOptions = {
|
|||||||
textChunkLimit: number;
|
textChunkLimit: number;
|
||||||
chunkText?: boolean;
|
chunkText?: boolean;
|
||||||
mediaMode?: "split" | "inline";
|
mediaMode?: "split" | "inline";
|
||||||
tableMode?: MarkdownTableMode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,19 +196,10 @@ 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 = getMSTeamsRuntime().channel.text.convertMarkdownTables(
|
const text = payload.text ?? "";
|
||||||
payload.text ?? "",
|
|
||||||
tableMode,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!text && mediaList.length === 0) continue;
|
if (!text && mediaList.length === 0) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,10 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||||||
).responsePrefix,
|
).responsePrefix,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
||||||
cfg: params.cfg,
|
|
||||||
channel: "msteams",
|
|
||||||
});
|
|
||||||
const messages = renderReplyPayloadsToMessages([payload], {
|
const messages = renderReplyPayloadsToMessages([payload], {
|
||||||
textChunkLimit: params.textLimit,
|
textChunkLimit: params.textLimit,
|
||||||
chunkText: true,
|
chunkText: true,
|
||||||
mediaMode: "split",
|
mediaMode: "split",
|
||||||
tableMode,
|
|
||||||
});
|
});
|
||||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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 = {
|
||||||
@@ -94,21 +93,13 @@ 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: messageText.length,
|
textLength: text.length,
|
||||||
hasMedia: Boolean(mediaUrl),
|
hasMedia: Boolean(mediaUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,7 +134,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: messageText || undefined,
|
description: text || 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 });
|
||||||
@@ -181,14 +172,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, messageText, finalMediaUrl);
|
return sendTextWithMedia(ctx, text, 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, messageText, finalMediaUrl);
|
return sendTextWithMedia(ctx, text, 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
|
||||||
@@ -232,7 +223,7 @@ export async function sendMessageMSTeams(
|
|||||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
||||||
const activity = {
|
const activity = {
|
||||||
type: "message",
|
type: "message",
|
||||||
text: messageText || undefined,
|
text: text || undefined,
|
||||||
attachments: [fileCardAttachment],
|
attachments: [fileCardAttachment],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,7 +264,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: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
|
text: text ? `${text}\n\n${fileLink}` : fileLink,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRef = buildConversationReference(ref);
|
const baseRef = buildConversationReference(ref);
|
||||||
@@ -299,7 +290,7 @@ export async function sendMessageMSTeams(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No media: send text only
|
// No media: send text only
|
||||||
return sendTextWithMedia(ctx, messageText, undefined);
|
return sendTextWithMedia(ctx, text, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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";
|
||||||
@@ -22,7 +21,6 @@ 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,18 +71,8 @@ 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,
|
message: text.trim(),
|
||||||
};
|
};
|
||||||
if (opts.replyTo) {
|
if (opts.replyTo) {
|
||||||
body.replyTo = opts.replyTo;
|
body.replyTo = opts.replyTo;
|
||||||
|
|||||||
@@ -133,20 +133,13 @@ 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, message);
|
await bus.sendDm(normalizedTo, text);
|
||||||
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,9 +63,6 @@ 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,4 +1,3 @@
|
|||||||
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()]);
|
||||||
@@ -6,7 +5,6 @@ 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, MarkdownTableMode } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
@@ -578,12 +578,6 @@ 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,
|
||||||
@@ -597,7 +591,6 @@ async function processMessageWithPipeline(params: {
|
|||||||
core,
|
core,
|
||||||
statusSink,
|
statusSink,
|
||||||
fetcher,
|
fetcher,
|
||||||
tableMode,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
@@ -615,11 +608,8 @@ 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
|
||||||
@@ -630,7 +620,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 ? text : undefined;
|
const caption = first ? payload.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);
|
||||||
@@ -642,8 +632,8 @@ async function deliverZaloReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (payload.text) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
|
const chunks = core.channel.text.chunkMarkdownText(payload.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,4 +1,3 @@
|
|||||||
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()]);
|
||||||
@@ -11,7 +10,6 @@ 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, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig, 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,11 +332,6 @@ 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) => {
|
||||||
@@ -356,11 +351,8 @@ 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
|
||||||
@@ -371,7 +363,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 ? text : undefined;
|
const caption = first ? payload.text : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
try {
|
try {
|
||||||
logVerbose(core, runtime, `Sending media to ${chatId}`);
|
logVerbose(core, runtime, `Sending media to ${chatId}`);
|
||||||
@@ -388,8 +380,8 @@ async function deliverZalouserReply(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (payload.text) {
|
||||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
|
const chunks = core.channel.text.chunkMarkdownText(payload.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 {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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,7 +16,6 @@ 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,7 +10,6 @@ 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,7 +75,6 @@ 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,6 +4,9 @@ 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")>();
|
||||||
@@ -20,7 +23,6 @@ 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,7 +21,6 @@ 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,7 +21,6 @@ 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,7 +21,6 @@ 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,7 +22,6 @@ 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,7 +21,6 @@ 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,7 +22,6 @@ 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
@@ -1,6 +1,5 @@
|
|||||||
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,7 +3,6 @@ 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,7 +2,6 @@ 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,7 +3,6 @@ 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";
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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,28 +45,20 @@ 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 = path.resolve(params.sessionFile);
|
const sessionFile = params.sessionFile;
|
||||||
const sessionDir = path.dirname(sessionFile);
|
const lockPath = `${sessionFile}.lock`;
|
||||||
await fs.mkdir(sessionDir, { recursive: true });
|
await fs.mkdir(path.dirname(lockPath), { 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(normalizedSessionFile);
|
const held = HELD_LOCKS.get(sessionFile);
|
||||||
if (held) {
|
if (held) {
|
||||||
held.count += 1;
|
held.count += 1;
|
||||||
return {
|
return {
|
||||||
release: async () => {
|
release: async () => {
|
||||||
const current = HELD_LOCKS.get(normalizedSessionFile);
|
const current = HELD_LOCKS.get(sessionFile);
|
||||||
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(normalizedSessionFile);
|
HELD_LOCKS.delete(sessionFile);
|
||||||
await current.handle.close();
|
await current.handle.close();
|
||||||
await fs.rm(current.lockPath, { force: true });
|
await fs.rm(current.lockPath, { force: true });
|
||||||
},
|
},
|
||||||
@@ -83,14 +75,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(normalizedSessionFile, { count: 1, handle, lockPath });
|
HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath });
|
||||||
return {
|
return {
|
||||||
release: async () => {
|
release: async () => {
|
||||||
const current = HELD_LOCKS.get(normalizedSessionFile);
|
const current = HELD_LOCKS.get(sessionFile);
|
||||||
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(normalizedSessionFile);
|
HELD_LOCKS.delete(sessionFile);
|
||||||
await current.handle.close();
|
await current.handle.close();
|
||||||
await fs.rm(current.lockPath, { force: true });
|
await fs.rm(current.lockPath, { force: true });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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,
|
|
||||||
}));
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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,122 @@
|
|||||||
|
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,14 +1,10 @@
|
|||||||
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 type { MsgContext } from "./templating.js";
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
|
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePluginRegistry(createTestRegistry([]));
|
setActivePluginRegistry(createTestRegistry([]));
|
||||||
@@ -18,123 +14,6 @@ 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({
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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,19 +2,14 @@ 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";
|
||||||
|
|
||||||
@@ -159,150 +154,3 @@ 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
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.
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.
+136
-41
@@ -2,79 +2,174 @@ import fs from "node:fs/promises";
|
|||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { MsgContext, TemplateContext } from "./templating.js";
|
|
||||||
|
|
||||||
const sandboxMocks = vi.hoisted(() => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
ensureSandboxWorkspaceForSession: vi.fn(),
|
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||||
|
compactEmbeddedPiSession: vi.fn(),
|
||||||
|
runEmbeddedPiAgent: vi.fn(),
|
||||||
|
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||||
|
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
|
||||||
|
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||||
|
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../agents/sandbox.js", () => sandboxMocks);
|
const usageMocks = vi.hoisted(() => ({
|
||||||
|
loadProviderUsageSummary: vi.fn().mockResolvedValue({
|
||||||
|
updatedAt: 0,
|
||||||
|
providers: [],
|
||||||
|
}),
|
||||||
|
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
|
||||||
|
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/provider-usage.js", () => usageMocks);
|
||||||
|
|
||||||
|
const modelCatalogMocks = vi.hoisted(() => ({
|
||||||
|
loadModelCatalog: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
provider: "anthropic",
|
||||||
|
id: "claude-opus-4-5",
|
||||||
|
name: "Claude Opus 4.5",
|
||||||
|
contextWindow: 200000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "openrouter",
|
||||||
|
id: "anthropic/claude-opus-4-5",
|
||||||
|
name: "Claude Opus 4.5 (OpenRouter)",
|
||||||
|
contextWindow: 200000,
|
||||||
|
},
|
||||||
|
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
|
||||||
|
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
|
||||||
|
{ provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" },
|
||||||
|
{ provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||||
|
]),
|
||||||
|
resetModelCatalogCacheForTest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
|
||||||
|
|
||||||
|
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||||
|
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
||||||
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
|
import { resolveAgentIdFromSessionKey, resolveSessionKey } from "../config/sessions.js";
|
||||||
|
import { getReplyFromConfig } from "./reply.js";
|
||||||
|
|
||||||
|
const _MAIN_SESSION_KEY = "agent:main:main";
|
||||||
|
|
||||||
|
const webMocks = vi.hoisted(() => ({
|
||||||
|
webAuthExists: vi.fn().mockResolvedValue(true),
|
||||||
|
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
|
||||||
|
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../web/session.js", () => webMocks);
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(async (home) => await fn(home), { prefix: "clawdbot-triggers-" });
|
return withTempHomeBase(
|
||||||
|
async (home) => {
|
||||||
|
vi.mocked(runEmbeddedPiAgent).mockClear();
|
||||||
|
vi.mocked(abortEmbeddedPiRun).mockClear();
|
||||||
|
return await fn(home);
|
||||||
|
},
|
||||||
|
{ prefix: "clawdbot-triggers-" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _makeCfg(home: string) {
|
||||||
|
return {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: join(home, "clawd"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
allowFrom: ["*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: { store: join(home, "sessions.json") },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("stageSandboxMedia", () => {
|
describe("trigger handling", () => {
|
||||||
it("stages inbound media into the sandbox workspace", async () => {
|
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const inboundDir = join(home, ".clawdbot", "media", "inbound");
|
const inboundDir = join(home, ".clawdbot", "media", "inbound");
|
||||||
await fs.mkdir(inboundDir, { recursive: true });
|
await fs.mkdir(inboundDir, { recursive: true });
|
||||||
const mediaPath = join(inboundDir, "photo.jpg");
|
const mediaPath = join(inboundDir, "photo.jpg");
|
||||||
await fs.writeFile(mediaPath, "test");
|
await fs.writeFile(mediaPath, "test");
|
||||||
|
|
||||||
const sandboxDir = join(home, "sandboxes", "session");
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
|
payloads: [{ text: "ok" }],
|
||||||
workspaceDir: sandboxDir,
|
meta: {
|
||||||
containerWorkdir: "/work",
|
durationMs: 1,
|
||||||
|
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx: MsgContext = {
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: join(home, "clawd"),
|
||||||
|
sandbox: {
|
||||||
|
mode: "non-main" as const,
|
||||||
|
workspaceRoot: join(home, "sandboxes"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
allowFrom: ["*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
store: join(home, "sessions.json"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
Body: "hi",
|
Body: "hi",
|
||||||
From: "whatsapp:group:demo",
|
From: "whatsapp:group:demo",
|
||||||
To: "+2000",
|
To: "+2000",
|
||||||
ChatType: "group",
|
ChatType: "group" as const,
|
||||||
Provider: "whatsapp",
|
Provider: "whatsapp" as const,
|
||||||
MediaPath: mediaPath,
|
MediaPath: mediaPath,
|
||||||
MediaType: "image/jpeg",
|
MediaType: "image/jpeg",
|
||||||
MediaUrl: mediaPath,
|
MediaUrl: mediaPath,
|
||||||
};
|
};
|
||||||
const sessionCtx: TemplateContext = { ...ctx };
|
|
||||||
|
|
||||||
await stageSandboxMedia({
|
const res = await getReplyFromConfig(ctx, {}, cfg);
|
||||||
ctx,
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
sessionCtx,
|
expect(text).toBe("ok");
|
||||||
cfg: {
|
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: "anthropic/claude-opus-4-5",
|
|
||||||
workspace: join(home, "clawd"),
|
|
||||||
sandbox: {
|
|
||||||
mode: "non-main",
|
|
||||||
workspaceRoot: join(home, "sandboxes"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
||||||
session: { store: join(home, "sessions.json") },
|
|
||||||
},
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
workspaceDir: join(home, "clawd"),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
||||||
const stagedPath = `media/inbound/${basename(mediaPath)}`;
|
const stagedPath = `media/inbound/${basename(mediaPath)}`;
|
||||||
expect(ctx.MediaPath).toBe(stagedPath);
|
expect(prompt).toContain(stagedPath);
|
||||||
expect(sessionCtx.MediaPath).toBe(stagedPath);
|
expect(prompt).not.toContain(mediaPath);
|
||||||
expect(ctx.MediaUrl).toBe(stagedPath);
|
|
||||||
expect(sessionCtx.MediaUrl).toBe(stagedPath);
|
|
||||||
|
|
||||||
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(mediaPath));
|
const sessionKey = resolveSessionKey(
|
||||||
|
cfg.session?.scope ?? "per-sender",
|
||||||
|
ctx,
|
||||||
|
cfg.session?.mainKey,
|
||||||
|
);
|
||||||
|
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||||
|
const sandbox = await ensureSandboxWorkspaceForSession({
|
||||||
|
config: cfg,
|
||||||
|
sessionKey,
|
||||||
|
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||||
|
});
|
||||||
|
expect(sandbox).not.toBeNull();
|
||||||
|
if (!sandbox) {
|
||||||
|
throw new Error("Expected sandbox to be set");
|
||||||
|
}
|
||||||
|
const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath));
|
||||||
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
|
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File renamed without changes.
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parseAudioTag } from "./audio-tags.js";
|
||||||
|
|
||||||
|
describe("parseAudioTag", () => {
|
||||||
|
it("detects audio_as_voice and strips the tag", () => {
|
||||||
|
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
||||||
|
expect(result.audioAsVoice).toBe(true);
|
||||||
|
expect(result.hadTag).toBe(true);
|
||||||
|
expect(result.text).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty output for missing text", () => {
|
||||||
|
const result = parseAudioTag(undefined);
|
||||||
|
expect(result.audioAsVoice).toBe(false);
|
||||||
|
expect(result.hadTag).toBe(false);
|
||||||
|
expect(result.text).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes tag-only messages", () => {
|
||||||
|
const result = parseAudioTag("[[audio_as_voice]]");
|
||||||
|
expect(result.audioAsVoice).toBe(true);
|
||||||
|
expect(result.text).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||||
|
|
||||||
|
describe("block reply coalescer", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces chunks within the idle window", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const flushes: string[] = [];
|
||||||
|
const coalescer = createBlockReplyCoalescer({
|
||||||
|
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
||||||
|
shouldAbort: () => false,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
flushes.push(payload.text ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "Hello" });
|
||||||
|
coalescer.enqueue({ text: "world" });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(flushes).toEqual(["Hello world"]);
|
||||||
|
coalescer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits until minChars before idle flush", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const flushes: string[] = [];
|
||||||
|
const coalescer = createBlockReplyCoalescer({
|
||||||
|
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
||||||
|
shouldAbort: () => false,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
flushes.push(payload.text ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "short" });
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(flushes).toEqual([]);
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "message" });
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(flushes).toEqual(["short message"]);
|
||||||
|
coalescer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushes buffered text before media payloads", () => {
|
||||||
|
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
|
||||||
|
const coalescer = createBlockReplyCoalescer({
|
||||||
|
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
|
||||||
|
shouldAbort: () => false,
|
||||||
|
onFlush: (payload) => {
|
||||||
|
flushes.push({
|
||||||
|
text: payload.text,
|
||||||
|
mediaUrls: payload.mediaUrls,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
coalescer.enqueue({ text: "Hello" });
|
||||||
|
coalescer.enqueue({ text: "world" });
|
||||||
|
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
|
||||||
|
void coalescer.flush({ force: true });
|
||||||
|
|
||||||
|
expect(flushes[0].text).toBe("Hello world");
|
||||||
|
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
|
||||||
|
coalescer.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||||
|
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../config/config.js", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||||
|
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||||
|
writeConfigFile: writeConfigFileMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||||
|
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||||
|
"../../pairing/pairing-store.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||||
|
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||||
|
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||||
|
"../../channels/plugins/pairing.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
listPairingChannels: () => ["telegram"],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||||
|
const ctx = {
|
||||||
|
Body: commandBody,
|
||||||
|
CommandBody: commandBody,
|
||||||
|
CommandSource: "text",
|
||||||
|
CommandAuthorized: true,
|
||||||
|
Provider: "telegram",
|
||||||
|
Surface: "telegram",
|
||||||
|
...ctxOverrides,
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const command = buildCommandContext({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
isGroup: false,
|
||||||
|
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
command,
|
||||||
|
directives: parseInlineDirectives(commandBody),
|
||||||
|
elevated: { enabled: true, allowed: true, failures: [] },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
defaultGroupActivation: () => "mention",
|
||||||
|
resolvedVerboseLevel: "off" as const,
|
||||||
|
resolvedReasoningLevel: "off" as const,
|
||||||
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
|
provider: "telegram",
|
||||||
|
model: "test-model",
|
||||||
|
contextTokens: 0,
|
||||||
|
isGroup: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("handleCommands /allowlist", () => {
|
||||||
|
it("lists config + store allowFrom entries", async () => {
|
||||||
|
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/allowlist list dm", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Channel: telegram");
|
||||||
|
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||||
|
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds entries to config and pairing store", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||||
|
changed: true,
|
||||||
|
allowFrom: ["123", "789"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/allowlist add dm 789", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||||
|
channel: "telegram",
|
||||||
|
entry: "789",
|
||||||
|
});
|
||||||
|
expect(result.reply?.text).toContain("DM allowlist added");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||||
|
const ctx = {
|
||||||
|
Body: commandBody,
|
||||||
|
CommandBody: commandBody,
|
||||||
|
CommandSource: "text",
|
||||||
|
CommandAuthorized: true,
|
||||||
|
Provider: "whatsapp",
|
||||||
|
Surface: "whatsapp",
|
||||||
|
...ctxOverrides,
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const command = buildCommandContext({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
isGroup: false,
|
||||||
|
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
command,
|
||||||
|
directives: parseInlineDirectives(commandBody),
|
||||||
|
elevated: { enabled: true, allowed: true, failures: [] },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
defaultGroupActivation: () => "mention",
|
||||||
|
resolvedVerboseLevel: "off" as const,
|
||||||
|
resolvedReasoningLevel: "off" as const,
|
||||||
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
|
provider: "whatsapp",
|
||||||
|
model: "test-model",
|
||||||
|
contextTokens: 0,
|
||||||
|
isGroup: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("handleCommands /config configWrites gating", () => {
|
||||||
|
it("blocks /config set when channel config writes are disabled", async () => {
|
||||||
|
const cfg = {
|
||||||
|
commands: { config: true, text: true },
|
||||||
|
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Config writes are disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-98
@@ -5,47 +5,6 @@ import type { MsgContext } from "../templating.js";
|
|||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
|
||||||
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
|
||||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("../../config/config.js", async () => {
|
|
||||||
const actual =
|
|
||||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
|
||||||
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
|
||||||
writeConfigFile: writeConfigFileMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
|
||||||
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
|
||||||
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("../../pairing/pairing-store.js", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
|
||||||
"../../pairing/pairing-store.js",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
|
||||||
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
|
||||||
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../../channels/plugins/pairing.js", async () => {
|
|
||||||
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
|
||||||
"../../channels/plugins/pairing.js",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
listPairingChannels: () => ["telegram"],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("../../agents/model-catalog.js", () => ({
|
vi.mock("../../agents/model-catalog.js", () => ({
|
||||||
loadModelCatalog: vi.fn(async () => [
|
loadModelCatalog: vi.fn(async () => [
|
||||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
||||||
@@ -87,70 +46,17 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
|
|||||||
resolvedVerboseLevel: "off" as const,
|
resolvedVerboseLevel: "off" as const,
|
||||||
resolvedReasoningLevel: "off" as const,
|
resolvedReasoningLevel: "off" as const,
|
||||||
resolveDefaultThinkingLevel: async () => undefined,
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
provider: "telegram",
|
provider: "anthropic",
|
||||||
model: "test-model",
|
model: "claude-opus-4-5",
|
||||||
contextTokens: 0,
|
contextTokens: 16000,
|
||||||
isGroup: false,
|
isGroup: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("handleCommands /allowlist", () => {
|
|
||||||
it("lists config + store allowFrom entries", async () => {
|
|
||||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true },
|
|
||||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
const params = buildParams("/allowlist list dm", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Channel: telegram");
|
|
||||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
|
||||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds entries to config and pairing store", async () => {
|
|
||||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
|
||||||
valid: true,
|
|
||||||
parsed: {
|
|
||||||
channels: { telegram: { allowFrom: ["123"] } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
|
||||||
ok: true,
|
|
||||||
config,
|
|
||||||
}));
|
|
||||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
|
||||||
changed: true,
|
|
||||||
allowFrom: ["123", "789"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
commands: { text: true, config: true },
|
|
||||||
channels: { telegram: { allowFrom: ["123"] } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
const params = buildParams("/allowlist add dm 789", cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
|
||||||
channel: "telegram",
|
|
||||||
entry: "789",
|
|
||||||
});
|
|
||||||
expect(result.reply?.text).toContain("DM allowlist added");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/models command", () => {
|
describe("/models command", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
commands: { text: true },
|
commands: { text: true },
|
||||||
|
// allowlist is empty => allowAny, but still okay for listing
|
||||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import type { MsgContext } from "../templating.js";
|
|
||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
|
||||||
import { extractMessageText } from "./commands-subagents.js";
|
|
||||||
import { parseConfigCommand } from "./config-commands.js";
|
|
||||||
import { parseDebugCommand } from "./debug-commands.js";
|
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
|
||||||
|
|
||||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
|
||||||
const ctx = {
|
|
||||||
Body: commandBody,
|
|
||||||
CommandBody: commandBody,
|
|
||||||
CommandSource: "text",
|
|
||||||
CommandAuthorized: true,
|
|
||||||
Provider: "whatsapp",
|
|
||||||
Surface: "whatsapp",
|
|
||||||
...ctxOverrides,
|
|
||||||
} as MsgContext;
|
|
||||||
|
|
||||||
const command = buildCommandContext({
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
isGroup: false,
|
|
||||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
|
||||||
commandAuthorized: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ctx,
|
|
||||||
cfg,
|
|
||||||
command,
|
|
||||||
directives: parseInlineDirectives(commandBody),
|
|
||||||
elevated: { enabled: true, allowed: true, failures: [] },
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
workspaceDir: "/tmp",
|
|
||||||
defaultGroupActivation: () => "mention",
|
|
||||||
resolvedVerboseLevel: "off" as const,
|
|
||||||
resolvedReasoningLevel: "off" as const,
|
|
||||||
resolveDefaultThinkingLevel: async () => undefined,
|
|
||||||
provider: "whatsapp",
|
|
||||||
model: "test-model",
|
|
||||||
contextTokens: 0,
|
|
||||||
isGroup: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("parseConfigCommand", () => {
|
|
||||||
it("parses show/unset", () => {
|
|
||||||
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
|
||||||
expect(parseConfigCommand("/config show")).toEqual({
|
|
||||||
action: "show",
|
|
||||||
path: undefined,
|
|
||||||
});
|
|
||||||
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
|
||||||
action: "show",
|
|
||||||
path: "foo.bar",
|
|
||||||
});
|
|
||||||
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
|
||||||
action: "show",
|
|
||||||
path: "foo.bar",
|
|
||||||
});
|
|
||||||
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
|
||||||
action: "unset",
|
|
||||||
path: "foo.bar",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses set with JSON", () => {
|
|
||||||
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
|
||||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseDebugCommand", () => {
|
|
||||||
it("parses show/reset", () => {
|
|
||||||
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
|
||||||
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
|
||||||
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses set with JSON", () => {
|
|
||||||
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
|
||||||
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("parses unset", () => {
|
|
||||||
const cmd = parseDebugCommand("/debug unset foo.bar");
|
|
||||||
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractMessageText", () => {
|
|
||||||
it("preserves user text that looks like tool call markers", () => {
|
|
||||||
const message = {
|
|
||||||
role: "user",
|
|
||||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
|
||||||
};
|
|
||||||
const result = extractMessageText(message);
|
|
||||||
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sanitizes assistant tool call markers", () => {
|
|
||||||
const message = {
|
|
||||||
role: "assistant",
|
|
||||||
content: "Here [Tool Call: foo (ID: 1)] ok",
|
|
||||||
};
|
|
||||||
const result = extractMessageText(message);
|
|
||||||
expect(result?.text).toBe("Here ok");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handleCommands /config configWrites gating", () => {
|
|
||||||
it("blocks /config set when channel config writes are disabled", async () => {
|
|
||||||
const cfg = {
|
|
||||||
commands: { config: true, text: true },
|
|
||||||
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
|
|
||||||
const result = await handleCommands(params);
|
|
||||||
expect(result.shouldContinue).toBe(false);
|
|
||||||
expect(result.reply?.text).toContain("Config writes are disabled");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { extractMessageText } from "./commands-subagents.js";
|
||||||
|
|
||||||
|
describe("extractMessageText", () => {
|
||||||
|
it("preserves user text that looks like tool call markers", () => {
|
||||||
|
const message = {
|
||||||
|
role: "user",
|
||||||
|
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||||
|
};
|
||||||
|
const result = extractMessageText(message);
|
||||||
|
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes assistant tool call markers", () => {
|
||||||
|
const message = {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Here [Tool Call: foo (ID: 1)] ok",
|
||||||
|
};
|
||||||
|
const result = extractMessageText(message);
|
||||||
|
expect(result?.text).toBe("Here ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parseConfigCommand } from "./config-commands.js";
|
||||||
|
|
||||||
|
describe("parseConfigCommand", () => {
|
||||||
|
it("parses show/unset", () => {
|
||||||
|
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
|
||||||
|
expect(parseConfigCommand("/config show")).toEqual({
|
||||||
|
action: "show",
|
||||||
|
path: undefined,
|
||||||
|
});
|
||||||
|
expect(parseConfigCommand("/config show foo.bar")).toEqual({
|
||||||
|
action: "show",
|
||||||
|
path: "foo.bar",
|
||||||
|
});
|
||||||
|
expect(parseConfigCommand("/config get foo.bar")).toEqual({
|
||||||
|
action: "show",
|
||||||
|
path: "foo.bar",
|
||||||
|
});
|
||||||
|
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
|
||||||
|
action: "unset",
|
||||||
|
path: "foo.bar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses set with JSON", () => {
|
||||||
|
const cmd = parseConfigCommand('/config set foo={"a":1}');
|
||||||
|
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parseDebugCommand } from "./debug-commands.js";
|
||||||
|
|
||||||
|
describe("parseDebugCommand", () => {
|
||||||
|
it("parses show/reset", () => {
|
||||||
|
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
|
||||||
|
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
|
||||||
|
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses set with JSON", () => {
|
||||||
|
const cmd = parseDebugCommand('/debug set foo={"a":1}');
|
||||||
|
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses unset", () => {
|
||||||
|
const cmd = parseDebugCommand("/debug unset foo.bar");
|
||||||
|
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-54
@@ -5,12 +5,8 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
|||||||
import type { SessionEntry } from "../../config/sessions.js";
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
import { handleDirectiveOnly } from "./directive-handling.impl.js";
|
import { handleDirectiveOnly } from "./directive-handling.impl.js";
|
||||||
import {
|
|
||||||
maybeHandleModelDirectiveInfo,
|
|
||||||
resolveModelSelectionFromDirective,
|
|
||||||
} from "./directive-handling.model.js";
|
|
||||||
|
|
||||||
// Mock dependencies for directive handling persistence.
|
// Mock dependencies
|
||||||
vi.mock("../../agents/agent-scope.js", () => ({
|
vi.mock("../../agents/agent-scope.js", () => ({
|
||||||
resolveAgentConfig: vi.fn(() => ({})),
|
resolveAgentConfig: vi.fn(() => ({})),
|
||||||
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
||||||
@@ -40,55 +36,6 @@ function baseConfig(): ClawdbotConfig {
|
|||||||
} as unknown as ClawdbotConfig;
|
} as unknown as ClawdbotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("/model chat UX", () => {
|
|
||||||
it("shows summary for /model with no args", async () => {
|
|
||||||
const directives = parseInlineDirectives("/model");
|
|
||||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
|
||||||
|
|
||||||
const reply = await maybeHandleModelDirectiveInfo({
|
|
||||||
directives,
|
|
||||||
cfg,
|
|
||||||
agentDir: "/tmp/agent",
|
|
||||||
activeAgentId: "main",
|
|
||||||
provider: "anthropic",
|
|
||||||
model: "claude-opus-4-5",
|
|
||||||
defaultProvider: "anthropic",
|
|
||||||
defaultModel: "claude-opus-4-5",
|
|
||||||
aliasIndex: baseAliasIndex(),
|
|
||||||
allowedModelCatalog: [],
|
|
||||||
resetModelOverride: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(reply?.text).toContain("Current:");
|
|
||||||
expect(reply?.text).toContain("Browse: /models");
|
|
||||||
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-applies closest match for typos", () => {
|
|
||||||
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
|
||||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
|
||||||
|
|
||||||
const resolved = resolveModelSelectionFromDirective({
|
|
||||||
directives,
|
|
||||||
cfg,
|
|
||||||
agentDir: "/tmp/agent",
|
|
||||||
defaultProvider: "anthropic",
|
|
||||||
defaultModel: "claude-opus-4-5",
|
|
||||||
aliasIndex: baseAliasIndex(),
|
|
||||||
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
|
|
||||||
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
|
|
||||||
provider: "anthropic",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolved.modelSelection).toEqual({
|
|
||||||
provider: "anthropic",
|
|
||||||
model: "claude-opus-4-5",
|
|
||||||
isDefault: true,
|
|
||||||
});
|
|
||||||
expect(resolved.errorText).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||||
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
|
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
|
||||||
const allowedModelCatalog = [
|
const allowedModelCatalog = [
|
||||||
@@ -159,6 +106,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
|||||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// No model directive = no model message
|
||||||
expect(result?.text ?? "").not.toContain("Model set to");
|
expect(result?.text ?? "").not.toContain("Model set to");
|
||||||
expect(result?.text ?? "").not.toContain("failed");
|
expect(result?.text ?? "").not.toContain("failed");
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ModelAliasIndex } from "../../agents/model-selection.js";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
import {
|
||||||
|
maybeHandleModelDirectiveInfo,
|
||||||
|
resolveModelSelectionFromDirective,
|
||||||
|
} from "./directive-handling.model.js";
|
||||||
|
|
||||||
|
function baseAliasIndex(): ModelAliasIndex {
|
||||||
|
return { byAlias: new Map(), byKey: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/model chat UX", () => {
|
||||||
|
it("shows summary for /model with no args", async () => {
|
||||||
|
const directives = parseInlineDirectives("/model");
|
||||||
|
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
const reply = await maybeHandleModelDirectiveInfo({
|
||||||
|
directives,
|
||||||
|
cfg,
|
||||||
|
agentDir: "/tmp/agent",
|
||||||
|
activeAgentId: "main",
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-5",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModel: "claude-opus-4-5",
|
||||||
|
aliasIndex: baseAliasIndex(),
|
||||||
|
allowedModelCatalog: [],
|
||||||
|
resetModelOverride: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reply?.text).toContain("Current:");
|
||||||
|
expect(reply?.text).toContain("Browse: /models");
|
||||||
|
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-applies closest match for typos", () => {
|
||||||
|
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
||||||
|
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||||
|
|
||||||
|
const resolved = resolveModelSelectionFromDirective({
|
||||||
|
directives,
|
||||||
|
cfg,
|
||||||
|
agentDir: "/tmp/agent",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModel: "claude-opus-4-5",
|
||||||
|
aliasIndex: baseAliasIndex(),
|
||||||
|
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
|
||||||
|
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
|
||||||
|
provider: "anthropic",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved.modelSelection).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-5",
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
expect(resolved.errorText).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
|
import type { FollowupRun } from "./queue.js";
|
||||||
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
|
const runEmbeddedPiAgentMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../agents/model-fallback.js", () => ({
|
||||||
|
runWithModelFallback: async ({
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
run,
|
||||||
|
}: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
run: (provider: string, model: string) => Promise<unknown>;
|
||||||
|
}) => ({
|
||||||
|
result: await run(provider, model),
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||||
|
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createFollowupRunner } from "./followup-runner.js";
|
||||||
|
|
||||||
|
describe("createFollowupRunner compaction", () => {
|
||||||
|
it("adds verbose auto-compaction notice and tracks count", async () => {
|
||||||
|
const storePath = path.join(
|
||||||
|
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
||||||
|
"sessions.json",
|
||||||
|
);
|
||||||
|
const sessionEntry: SessionEntry = {
|
||||||
|
sessionId: "session",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
const sessionStore: Record<string, SessionEntry> = {
|
||||||
|
main: sessionEntry,
|
||||||
|
};
|
||||||
|
const onBlockReply = vi.fn(async () => {});
|
||||||
|
|
||||||
|
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||||
|
async (params: {
|
||||||
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||||
|
}) => {
|
||||||
|
params.onAgentEvent?.({
|
||||||
|
stream: "compaction",
|
||||||
|
data: { phase: "end", willRetry: false },
|
||||||
|
});
|
||||||
|
return { payloads: [{ text: "final" }], meta: {} };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const runner = createFollowupRunner({
|
||||||
|
opts: { onBlockReply },
|
||||||
|
typing: createMockTypingController(),
|
||||||
|
typingMode: "instant",
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey: "main",
|
||||||
|
storePath,
|
||||||
|
defaultModel: "anthropic/claude-opus-4-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
const queued = {
|
||||||
|
prompt: "hello",
|
||||||
|
summaryLine: "hello",
|
||||||
|
enqueuedAt: Date.now(),
|
||||||
|
run: {
|
||||||
|
sessionId: "session",
|
||||||
|
sessionKey: "main",
|
||||||
|
messageProvider: "whatsapp",
|
||||||
|
sessionFile: "/tmp/session.jsonl",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
config: {},
|
||||||
|
skillsSnapshot: {},
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude",
|
||||||
|
thinkLevel: "low",
|
||||||
|
verboseLevel: "on",
|
||||||
|
elevatedLevel: "off",
|
||||||
|
bashElevated: {
|
||||||
|
enabled: false,
|
||||||
|
allowed: false,
|
||||||
|
defaultLevel: "off",
|
||||||
|
},
|
||||||
|
timeoutMs: 1_000,
|
||||||
|
blockReplyBreak: "message_end",
|
||||||
|
},
|
||||||
|
} as FollowupRun;
|
||||||
|
|
||||||
|
await runner(queued);
|
||||||
|
|
||||||
|
expect(onBlockReply).toHaveBeenCalled();
|
||||||
|
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
|
||||||
|
expect(sessionStore.main.compactionCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
-77
@@ -1,9 +1,5 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { SessionEntry } from "../../config/sessions.js";
|
|
||||||
import type { FollowupRun } from "./queue.js";
|
import type { FollowupRun } from "./queue.js";
|
||||||
import { createMockTypingController } from "./test-helpers.js";
|
import { createMockTypingController } from "./test-helpers.js";
|
||||||
|
|
||||||
@@ -61,79 +57,6 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
|
|||||||
},
|
},
|
||||||
}) as FollowupRun;
|
}) as FollowupRun;
|
||||||
|
|
||||||
describe("createFollowupRunner compaction", () => {
|
|
||||||
it("adds verbose auto-compaction notice and tracks count", async () => {
|
|
||||||
const storePath = path.join(
|
|
||||||
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
|
||||||
"sessions.json",
|
|
||||||
);
|
|
||||||
const sessionEntry: SessionEntry = {
|
|
||||||
sessionId: "session",
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
const sessionStore: Record<string, SessionEntry> = {
|
|
||||||
main: sessionEntry,
|
|
||||||
};
|
|
||||||
const onBlockReply = vi.fn(async () => {});
|
|
||||||
|
|
||||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
|
||||||
async (params: {
|
|
||||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
|
||||||
}) => {
|
|
||||||
params.onAgentEvent?.({
|
|
||||||
stream: "compaction",
|
|
||||||
data: { phase: "end", willRetry: false },
|
|
||||||
});
|
|
||||||
return { payloads: [{ text: "final" }], meta: {} };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const runner = createFollowupRunner({
|
|
||||||
opts: { onBlockReply },
|
|
||||||
typing: createMockTypingController(),
|
|
||||||
typingMode: "instant",
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey: "main",
|
|
||||||
storePath,
|
|
||||||
defaultModel: "anthropic/claude-opus-4-5",
|
|
||||||
});
|
|
||||||
|
|
||||||
const queued = {
|
|
||||||
prompt: "hello",
|
|
||||||
summaryLine: "hello",
|
|
||||||
enqueuedAt: Date.now(),
|
|
||||||
run: {
|
|
||||||
sessionId: "session",
|
|
||||||
sessionKey: "main",
|
|
||||||
messageProvider: "whatsapp",
|
|
||||||
sessionFile: "/tmp/session.jsonl",
|
|
||||||
workspaceDir: "/tmp",
|
|
||||||
config: {},
|
|
||||||
skillsSnapshot: {},
|
|
||||||
provider: "anthropic",
|
|
||||||
model: "claude",
|
|
||||||
thinkLevel: "low",
|
|
||||||
verboseLevel: "on",
|
|
||||||
elevatedLevel: "off",
|
|
||||||
bashElevated: {
|
|
||||||
enabled: false,
|
|
||||||
allowed: false,
|
|
||||||
defaultLevel: "off",
|
|
||||||
},
|
|
||||||
timeoutMs: 1_000,
|
|
||||||
blockReplyBreak: "message_end",
|
|
||||||
},
|
|
||||||
} as FollowupRun;
|
|
||||||
|
|
||||||
await runner(queued);
|
|
||||||
|
|
||||||
expect(onBlockReply).toHaveBeenCalled();
|
|
||||||
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
|
|
||||||
expect(sessionStore.main.compactionCount).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createFollowupRunner messaging tool dedupe", () => {
|
describe("createFollowupRunner messaging tool dedupe", () => {
|
||||||
it("drops payloads already sent via messaging tool", async () => {
|
it("drops payloads already sent via messaging tool", async () => {
|
||||||
const onBlockReply = vi.fn(async () => {});
|
const onBlockReply = vi.fn(async () => {});
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { parseAudioTag } from "./audio-tags.js";
|
|
||||||
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
|
||||||
import { createReplyReferencePlanner } from "./reply-reference.js";
|
|
||||||
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
|
|
||||||
|
|
||||||
describe("parseAudioTag", () => {
|
|
||||||
it("detects audio_as_voice and strips the tag", () => {
|
|
||||||
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
|
||||||
expect(result.audioAsVoice).toBe(true);
|
|
||||||
expect(result.hadTag).toBe(true);
|
|
||||||
expect(result.text).toBe("Hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty output for missing text", () => {
|
|
||||||
const result = parseAudioTag(undefined);
|
|
||||||
expect(result.audioAsVoice).toBe(false);
|
|
||||||
expect(result.hadTag).toBe(false);
|
|
||||||
expect(result.text).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes tag-only messages", () => {
|
|
||||||
const result = parseAudioTag("[[audio_as_voice]]");
|
|
||||||
expect(result.audioAsVoice).toBe(true);
|
|
||||||
expect(result.text).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("block reply coalescer", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("coalesces chunks within the idle window", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const flushes: string[] = [];
|
|
||||||
const coalescer = createBlockReplyCoalescer({
|
|
||||||
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
|
||||||
shouldAbort: () => false,
|
|
||||||
onFlush: (payload) => {
|
|
||||||
flushes.push(payload.text ?? "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
coalescer.enqueue({ text: "Hello" });
|
|
||||||
coalescer.enqueue({ text: "world" });
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
expect(flushes).toEqual(["Hello world"]);
|
|
||||||
coalescer.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("waits until minChars before idle flush", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const flushes: string[] = [];
|
|
||||||
const coalescer = createBlockReplyCoalescer({
|
|
||||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
|
||||||
shouldAbort: () => false,
|
|
||||||
onFlush: (payload) => {
|
|
||||||
flushes.push(payload.text ?? "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
coalescer.enqueue({ text: "short" });
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
|
||||||
expect(flushes).toEqual([]);
|
|
||||||
|
|
||||||
coalescer.enqueue({ text: "message" });
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
|
||||||
expect(flushes).toEqual(["short message"]);
|
|
||||||
coalescer.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flushes buffered text before media payloads", () => {
|
|
||||||
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
|
|
||||||
const coalescer = createBlockReplyCoalescer({
|
|
||||||
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
|
|
||||||
shouldAbort: () => false,
|
|
||||||
onFlush: (payload) => {
|
|
||||||
flushes.push({
|
|
||||||
text: payload.text,
|
|
||||||
mediaUrls: payload.mediaUrls,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
coalescer.enqueue({ text: "Hello" });
|
|
||||||
coalescer.enqueue({ text: "world" });
|
|
||||||
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
|
|
||||||
void coalescer.flush({ force: true });
|
|
||||||
|
|
||||||
expect(flushes[0].text).toBe("Hello world");
|
|
||||||
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
|
|
||||||
coalescer.stop();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createReplyReferencePlanner", () => {
|
|
||||||
it("disables references when mode is off", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "off",
|
|
||||||
startId: "parent",
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBeUndefined();
|
|
||||||
expect(planner.hasReplied()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses startId once when mode is first", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "first",
|
|
||||||
startId: "parent",
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBe("parent");
|
|
||||||
expect(planner.hasReplied()).toBe(true);
|
|
||||||
planner.markSent();
|
|
||||||
expect(planner.use()).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns startId for every call when mode is all", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "all",
|
|
||||||
startId: "parent",
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBe("parent");
|
|
||||||
expect(planner.use()).toBe("parent");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers existing thread id regardless of mode", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "off",
|
|
||||||
existingId: "thread-1",
|
|
||||||
startId: "parent",
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBe("thread-1");
|
|
||||||
expect(planner.hasReplied()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honors allowReference=false", () => {
|
|
||||||
const planner = createReplyReferencePlanner({
|
|
||||||
replyToMode: "all",
|
|
||||||
startId: "parent",
|
|
||||||
allowReference: false,
|
|
||||||
});
|
|
||||||
expect(planner.use()).toBeUndefined();
|
|
||||||
expect(planner.hasReplied()).toBe(false);
|
|
||||||
planner.markSent();
|
|
||||||
expect(planner.hasReplied()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createStreamingDirectiveAccumulator", () => {
|
|
||||||
it("stashes reply_to_current until a renderable chunk arrives", () => {
|
|
||||||
const accumulator = createStreamingDirectiveAccumulator();
|
|
||||||
|
|
||||||
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
|
|
||||||
|
|
||||||
const result = accumulator.consume("Hello");
|
|
||||||
expect(result?.text).toBe("Hello");
|
|
||||||
expect(result?.replyToCurrent).toBe(true);
|
|
||||||
expect(result?.replyToTag).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles reply tags split across chunks", () => {
|
|
||||||
const accumulator = createStreamingDirectiveAccumulator();
|
|
||||||
|
|
||||||
expect(accumulator.consume("[[reply_to_")).toBeNull();
|
|
||||||
|
|
||||||
const result = accumulator.consume("current]] Yo");
|
|
||||||
expect(result?.text).toBe("Yo");
|
|
||||||
expect(result?.replyToCurrent).toBe(true);
|
|
||||||
expect(result?.replyToTag).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates explicit reply ids across chunks", () => {
|
|
||||||
const accumulator = createStreamingDirectiveAccumulator();
|
|
||||||
|
|
||||||
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
|
|
||||||
|
|
||||||
const result = accumulator.consume("Hi");
|
|
||||||
expect(result?.text).toBe("Hi");
|
|
||||||
expect(result?.replyToId).toBe("abc-123");
|
|
||||||
expect(result?.replyToTag).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -28,7 +28,6 @@ export async function getReplyFromConfig(
|
|||||||
opts?: GetReplyOptions,
|
opts?: GetReplyOptions,
|
||||||
configOverride?: ClawdbotConfig,
|
configOverride?: ClawdbotConfig,
|
||||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||||
const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1";
|
|
||||||
const cfg = configOverride ?? loadConfig();
|
const cfg = configOverride ?? loadConfig();
|
||||||
const targetSessionKey =
|
const targetSessionKey =
|
||||||
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
|
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
|
||||||
@@ -63,7 +62,7 @@ export async function getReplyFromConfig(
|
|||||||
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||||
const workspace = await ensureAgentWorkspace({
|
const workspace = await ensureAgentWorkspace({
|
||||||
dir: workspaceDirRaw,
|
dir: workspaceDirRaw,
|
||||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
|
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||||
});
|
});
|
||||||
const workspaceDir = workspace.dir;
|
const workspaceDir = workspace.dir;
|
||||||
const agentDir = resolveAgentDir(cfg, agentId);
|
const agentDir = resolveAgentDir(cfg, agentId);
|
||||||
@@ -82,14 +81,12 @@ export async function getReplyFromConfig(
|
|||||||
|
|
||||||
const finalized = finalizeInboundContext(ctx);
|
const finalized = finalizeInboundContext(ctx);
|
||||||
|
|
||||||
if (!isFastTestEnv) {
|
await applyMediaUnderstanding({
|
||||||
await applyMediaUnderstanding({
|
ctx: finalized,
|
||||||
ctx: finalized,
|
cfg,
|
||||||
cfg,
|
agentDir,
|
||||||
agentDir,
|
activeModel: { provider, model },
|
||||||
activeModel: { provider, model },
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandAuthorized = finalized.CommandAuthorized;
|
const commandAuthorized = finalized.CommandAuthorized;
|
||||||
resolveCommandAuthorization({
|
resolveCommandAuthorization({
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { GroupKeyResolution } from "../../config/sessions.js";
|
||||||
|
import type { TemplateContext } from "../templating.js";
|
||||||
|
import { resolveGroupRequireMention } from "./groups.js";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { finalizeInboundContext } from "./inbound-context.js";
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import {
|
||||||
|
buildInboundDedupeKey,
|
||||||
|
resetInboundDedupe,
|
||||||
|
shouldSkipDuplicateInbound,
|
||||||
|
} from "./inbound-dedupe.js";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user