Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375112b5b1 | ||
|
|
5ddf86378c | ||
|
|
1cffb4a914 | ||
|
|
e964db95b9 | ||
|
|
1af227b619 | ||
|
|
b77e730657 | ||
|
|
37e5f077b8 | ||
|
|
0eb7e1864c | ||
|
|
0d336272f9 | ||
|
|
ace6a42ea6 | ||
|
|
6d2a1ce217 | ||
|
|
c9d73469c3 | ||
|
|
29353e2e81 | ||
|
|
fdc50a0feb | ||
|
|
a1413a011e | ||
|
|
bfbeea0f20 | ||
|
|
2c85b1b409 |
No files matched your search
+6
-1
@@ -4,10 +4,15 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
## 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
|
||||
- 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.
|
||||
- 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)
|
||||
- Daemon: prefer symlinked CLI paths to keep service configs stable. (#1505) Thanks @odysseus0.
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
|
||||
+5
-4
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)"
|
||||
summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)"
|
||||
read_when:
|
||||
- You want to update a source checkout safely
|
||||
- You need to understand `--update` shorthand behavior
|
||||
@@ -20,14 +20,14 @@ clawdbot update wizard
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --channel dev
|
||||
clawdbot update --tag beta
|
||||
clawdbot update --restart
|
||||
clawdbot update --no-restart
|
||||
clawdbot update --json
|
||||
clawdbot --update
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--restart`: restart the Gateway service after a successful update.
|
||||
- `--no-restart`: skip restarting the Gateway service after a successful update.
|
||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
@@ -52,7 +52,8 @@ Options:
|
||||
## `update wizard`
|
||||
|
||||
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
||||
after updating. If you select `dev` without a git checkout, it offers to create one.
|
||||
after updating (default is to restart). If you select `dev` without a git checkout, it
|
||||
offers to create one.
|
||||
|
||||
## What it does
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ stay consistent across channels.
|
||||
1. **Parse Markdown -> IR**
|
||||
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
|
||||
- Offsets are UTF-16 code units so Signal style ranges align with its API.
|
||||
- Tables are parsed only when a channel opts into table conversion.
|
||||
2. **Chunk IR (format-first)**
|
||||
- Chunking happens on the IR text before rendering.
|
||||
- Inline formatting does not split across chunks; spans are sliced per chunk.
|
||||
@@ -59,7 +60,30 @@ IR (schematic):
|
||||
|
||||
- Slack, Telegram, and Signal outbound adapters render from the IR.
|
||||
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
|
||||
their own formatting rules.
|
||||
their own formatting rules, with Markdown table conversion applied before
|
||||
chunking when enabled.
|
||||
|
||||
## Table handling
|
||||
|
||||
Markdown tables are not consistently supported across chat clients. Use
|
||||
`markdown.tables` to control conversion per channel (and per account).
|
||||
|
||||
- `code`: render tables as code blocks (default for most channels).
|
||||
- `bullets`: convert each row into bullet points (default for Signal + WhatsApp).
|
||||
- `off`: disable table parsing and conversion; raw table text passes through.
|
||||
|
||||
Config keys:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
discord:
|
||||
markdown:
|
||||
tables: code
|
||||
accounts:
|
||||
work:
|
||||
markdown:
|
||||
tables: off
|
||||
```
|
||||
|
||||
## Chunking rules
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# Updating
|
||||
|
||||
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
|
||||
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart (or use `clawdbot update`, which restarts) → verify.
|
||||
|
||||
## Recommended: re-run the website installer (upgrade in place)
|
||||
|
||||
@@ -81,7 +81,7 @@ Notes:
|
||||
For **source installs** (git checkout), prefer:
|
||||
|
||||
```bash
|
||||
clawdbot update --restart
|
||||
clawdbot update
|
||||
```
|
||||
|
||||
It runs a safe-ish update flow:
|
||||
@@ -89,6 +89,7 @@ It runs a safe-ish update flow:
|
||||
- Switches to the selected channel (tag or branch).
|
||||
- Fetches + rebases against the configured upstream (dev channel).
|
||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||
- Restarts the gateway by default (use `--no-restart` to skip).
|
||||
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -25,6 +26,7 @@ const bluebubblesGroupConfigSchema = z.object({
|
||||
const bluebubblesAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
serverUrl: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
|
||||
@@ -99,6 +99,8 @@ function createMockRuntime(): PluginRuntime {
|
||||
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
|
||||
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
|
||||
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
|
||||
resolveMarkdownTableMode: vi.fn(() => "code") as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
||||
convertMarkdownTables: vi.fn((text: string) => text) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
@@ -220,6 +222,12 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb
|
||||
return res;
|
||||
}
|
||||
|
||||
const flushAsync = async () => {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
|
||||
describe("BlueBubbles webhook monitor", () => {
|
||||
let unregister: () => void;
|
||||
|
||||
@@ -506,7 +514,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -554,7 +562,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||
@@ -601,7 +609,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
// Wait for async processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
@@ -640,7 +648,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
@@ -681,7 +689,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
@@ -724,7 +732,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockUpsertPairingRequest).toHaveBeenCalled();
|
||||
// Should not send pairing reply since created=false
|
||||
@@ -765,7 +773,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
@@ -802,7 +810,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -842,7 +850,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
@@ -880,7 +888,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -919,7 +927,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -958,7 +966,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
@@ -999,7 +1007,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1040,7 +1048,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1078,7 +1086,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1121,7 +1129,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1167,7 +1175,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1213,7 +1221,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const originalRes = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
// Only assert the reply message behavior below.
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||
@@ -1237,7 +1245,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const replyRes = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1283,7 +1291,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1331,7 +1339,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -1384,7 +1392,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
// Should process even without mention because it's an authorized control command
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
@@ -1427,7 +1435,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1470,7 +1478,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(markBlueBubblesChatRead).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1511,7 +1519,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1554,7 +1562,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
// Should call typing start when reply flow triggers it.
|
||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||
@@ -1604,7 +1612,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
@@ -1649,7 +1657,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
@@ -1697,7 +1705,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
@@ -1742,7 +1750,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reaction added"),
|
||||
@@ -1782,7 +1790,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reaction removed"),
|
||||
@@ -1822,7 +1830,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1860,7 +1868,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("👍"),
|
||||
@@ -1901,7 +1909,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
@@ -1941,7 +1949,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
// The short ID "1" should resolve back to the full UUID
|
||||
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
||||
@@ -1993,7 +2001,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1662,9 +1662,15 @@ async function processMessage(
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
if (mediaList.length > 0) {
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? payload.text : undefined;
|
||||
const caption = first ? text : undefined;
|
||||
first = false;
|
||||
const result = await sendBlueBubblesMedia({
|
||||
cfg: config,
|
||||
@@ -1686,8 +1692,14 @@ async function processMessage(
|
||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||
? account.config.textChunkLimit
|
||||
: DEFAULT_TEXT_LIMIT;
|
||||
const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit);
|
||||
if (!chunks.length && payload.text) chunks.push(payload.text);
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
if (!chunks.length && text) chunks.push(text);
|
||||
if (!chunks.length) return;
|
||||
for (const chunk of chunks) {
|
||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -35,6 +36,7 @@ const matrixRoomSchema = z
|
||||
export const MatrixConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
homeserver: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
|
||||
@@ -548,6 +548,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||
@@ -562,6 +567,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
textLimit,
|
||||
replyToMode,
|
||||
threadId: threadTarget,
|
||||
accountId: route.accountId,
|
||||
tableMode,
|
||||
});
|
||||
didSendReply = true;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
@@ -12,8 +12,17 @@ export async function deliverMatrixReplies(params: {
|
||||
textLimit: number;
|
||||
replyToMode: "off" | "first" | "all";
|
||||
threadId?: string;
|
||||
accountId?: string;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const core = getMatrixRuntime();
|
||||
const tableMode =
|
||||
params.tableMode ??
|
||||
core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: core.config.loadConfig(),
|
||||
channel: "matrix",
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const logVerbose = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
params.runtime.log?.(message);
|
||||
@@ -33,6 +42,8 @@ export async function deliverMatrixReplies(params: {
|
||||
}
|
||||
const replyToIdRaw = reply.replyToId?.trim();
|
||||
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
||||
const rawText = reply.text ?? "";
|
||||
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
|
||||
const mediaList = reply.mediaUrls?.length
|
||||
? reply.mediaUrls
|
||||
: reply.mediaUrl
|
||||
@@ -43,13 +54,14 @@ export async function deliverMatrixReplies(params: {
|
||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
||||
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) continue;
|
||||
await sendMessageMatrix(params.roomId, trimmed, {
|
||||
client: params.client,
|
||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||
threadId: params.threadId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (shouldIncludeReply(replyToId)) {
|
||||
hasReplied = true;
|
||||
@@ -60,13 +72,14 @@ export async function deliverMatrixReplies(params: {
|
||||
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? (reply.text ?? "") : "";
|
||||
const caption = first ? text : "";
|
||||
await sendMessageMatrix(params.roomId, caption, {
|
||||
client: params.client,
|
||||
mediaUrl,
|
||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||
threadId: params.threadId,
|
||||
audioAsVoice: reply.audioAsVoice,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (shouldIncludeReply(replyToId)) {
|
||||
hasReplied = true;
|
||||
|
||||
@@ -43,6 +43,8 @@ const runtimeStub = {
|
||||
text: {
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||
resolveMarkdownTableMode: () => "code",
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
@@ -50,9 +50,18 @@ export async function sendMessageMatrix(
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
const cfg = getCore().config.loadConfig();
|
||||
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const convertedMessage = getCore().channel.text.convertMarkdownTables(
|
||||
trimmedMessage,
|
||||
tableMode,
|
||||
);
|
||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
|
||||
const threadId = normalizeThreadId(opts.threadId);
|
||||
const relation = threadId
|
||||
? buildThreadRelation(threadId, opts.replyToId)
|
||||
|
||||
@@ -87,6 +87,7 @@ export type MatrixSendResult = {
|
||||
export type MatrixSendOpts = {
|
||||
client?: import("matrix-bot-sdk").MatrixClient;
|
||||
mediaUrl?: string;
|
||||
accountId?: string;
|
||||
replyToId?: string;
|
||||
threadId?: string | number | null;
|
||||
timeoutMs?: number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
@@ -11,6 +12,7 @@ const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
botToken: z.string().optional(),
|
||||
|
||||
@@ -707,6 +707,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
||||
fallbackLimit: account.textChunkLimit ?? 4000,
|
||||
});
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
let prefixContext: ResponsePrefixContext = {
|
||||
identityName: resolveIdentityName(cfg, route.agentId),
|
||||
@@ -720,7 +725,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
if (mediaUrls.length === 0) {
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||
|
||||
@@ -181,6 +181,15 @@ export async function sendMessageMattermost(
|
||||
}
|
||||
}
|
||||
|
||||
if (message) {
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
message = core.channel.text.convertMarkdownTables(message, tableMode);
|
||||
}
|
||||
|
||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
||||
if (uploadError) {
|
||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
||||
@@ -205,4 +214,4 @@ export async function sendMessageMattermost(
|
||||
messageId: post.id ?? "unknown",
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ const runtimeStub = {
|
||||
}
|
||||
return chunks;
|
||||
},
|
||||
resolveMarkdownTableMode: () => "code",
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
@@ -34,6 +36,7 @@ describe("msteams messenger", () => {
|
||||
it("filters silent replies", () => {
|
||||
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
|
||||
textChunkLimit: 4000,
|
||||
tableMode: "code",
|
||||
});
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
@@ -41,7 +44,7 @@ describe("msteams messenger", () => {
|
||||
it("filters silent reply prefixes", () => {
|
||||
const messages = renderReplyPayloadsToMessages(
|
||||
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
|
||||
{ textChunkLimit: 4000 },
|
||||
{ textChunkLimit: 4000, tableMode: "code" },
|
||||
);
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
@@ -49,7 +52,7 @@ describe("msteams messenger", () => {
|
||||
it("splits media into separate messages by default", () => {
|
||||
const messages = renderReplyPayloadsToMessages(
|
||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||
{ textChunkLimit: 4000 },
|
||||
{ textChunkLimit: 4000, tableMode: "code" },
|
||||
);
|
||||
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
|
||||
});
|
||||
@@ -57,7 +60,7 @@ describe("msteams messenger", () => {
|
||||
it("supports inline media mode", () => {
|
||||
const messages = renderReplyPayloadsToMessages(
|
||||
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
|
||||
{ textChunkLimit: 4000, mediaMode: "inline" },
|
||||
{ textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
|
||||
);
|
||||
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
|
||||
});
|
||||
@@ -66,6 +69,7 @@ describe("msteams messenger", () => {
|
||||
const long = "hello ".repeat(200);
|
||||
const messages = renderReplyPayloadsToMessages([{ text: long }], {
|
||||
textChunkLimit: 50,
|
||||
tableMode: "code",
|
||||
});
|
||||
expect(messages.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
isSilentReplyText,
|
||||
loadWebMedia,
|
||||
type MarkdownTableMode,
|
||||
type MSTeamsReplyStyle,
|
||||
type ReplyPayload,
|
||||
SILENT_REPLY_TOKEN,
|
||||
@@ -61,6 +62,7 @@ export type MSTeamsReplyRenderOptions = {
|
||||
textChunkLimit: number;
|
||||
chunkText?: boolean;
|
||||
mediaMode?: "split" | "inline";
|
||||
tableMode?: MarkdownTableMode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -196,10 +198,19 @@ export function renderReplyPayloadsToMessages(
|
||||
const chunkLimit = Math.min(options.textChunkLimit, 4000);
|
||||
const chunkText = options.chunkText !== false;
|
||||
const mediaMode = options.mediaMode ?? "split";
|
||||
const tableMode =
|
||||
options.tableMode ??
|
||||
getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg: getMSTeamsRuntime().config.loadConfig(),
|
||||
channel: "msteams",
|
||||
});
|
||||
|
||||
for (const payload of replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
|
||||
payload.text ?? "",
|
||||
tableMode,
|
||||
);
|
||||
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
|
||||
|
||||
@@ -53,10 +53,15 @@ export function createMSTeamsReplyDispatcher(params: {
|
||||
).responsePrefix,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
|
||||
deliver: async (payload) => {
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: params.cfg,
|
||||
channel: "msteams",
|
||||
});
|
||||
const messages = renderReplyPayloadsToMessages([payload], {
|
||||
textChunkLimit: params.textLimit,
|
||||
chunkText: true,
|
||||
mediaMode: "split",
|
||||
tableMode,
|
||||
});
|
||||
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { extractFilename, extractMessageId } from "./media-helpers.js";
|
||||
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
|
||||
import { buildMSTeamsPollCard } from "./polls.js";
|
||||
import { getMSTeamsRuntime } from "./runtime.js";
|
||||
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
|
||||
|
||||
export type SendMSTeamsMessageParams = {
|
||||
@@ -93,13 +94,21 @@ export async function sendMessageMSTeams(
|
||||
params: SendMSTeamsMessageParams,
|
||||
): Promise<SendMSTeamsMessageResult> {
|
||||
const { cfg, to, text, mediaUrl } = params;
|
||||
const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
});
|
||||
const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables(
|
||||
text ?? "",
|
||||
tableMode,
|
||||
);
|
||||
const ctx = await resolveMSTeamsSendContext({ cfg, to });
|
||||
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
|
||||
|
||||
log.debug("sending proactive message", {
|
||||
conversationId,
|
||||
conversationType,
|
||||
textLength: text.length,
|
||||
textLength: messageText.length,
|
||||
hasMedia: Boolean(mediaUrl),
|
||||
});
|
||||
|
||||
@@ -134,7 +143,7 @@ export async function sendMessageMSTeams(
|
||||
const { activity, uploadId } = prepareFileConsentActivity({
|
||||
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
|
||||
conversationId,
|
||||
description: text || undefined,
|
||||
description: messageText || undefined,
|
||||
});
|
||||
|
||||
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
||||
@@ -172,14 +181,14 @@ export async function sendMessageMSTeams(
|
||||
const base64 = media.buffer.toString("base64");
|
||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||
|
||||
return sendTextWithMedia(ctx, text, finalMediaUrl);
|
||||
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
|
||||
}
|
||||
|
||||
if (isImage && !sharePointSiteId) {
|
||||
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
|
||||
const base64 = media.buffer.toString("base64");
|
||||
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
|
||||
return sendTextWithMedia(ctx, text, finalMediaUrl);
|
||||
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
|
||||
}
|
||||
|
||||
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
|
||||
@@ -223,7 +232,7 @@ export async function sendMessageMSTeams(
|
||||
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
|
||||
const activity = {
|
||||
type: "message",
|
||||
text: text || undefined,
|
||||
text: messageText || undefined,
|
||||
attachments: [fileCardAttachment],
|
||||
};
|
||||
|
||||
@@ -264,7 +273,7 @@ export async function sendMessageMSTeams(
|
||||
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
|
||||
const activity = {
|
||||
type: "message",
|
||||
text: text ? `${text}\n\n${fileLink}` : fileLink,
|
||||
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
|
||||
};
|
||||
|
||||
const baseRef = buildConversationReference(ref);
|
||||
@@ -290,7 +299,7 @@ export async function sendMessageMSTeams(
|
||||
}
|
||||
|
||||
// No media: send text only
|
||||
return sendTextWithMedia(ctx, text, undefined);
|
||||
return sendTextWithMedia(ctx, messageText, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
@@ -21,6 +22,7 @@ export const NextcloudTalkAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
baseUrl: z.string().optional(),
|
||||
botSecret: z.string().optional(),
|
||||
botSecretFile: z.string().optional(),
|
||||
|
||||
@@ -71,8 +71,18 @@ export async function sendMessageNextcloudTalk(
|
||||
throw new Error("Message must be non-empty for Nextcloud Talk sends");
|
||||
}
|
||||
|
||||
const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "nextcloud-talk",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables(
|
||||
text.trim(),
|
||||
tableMode,
|
||||
);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
message: text.trim(),
|
||||
message,
|
||||
};
|
||||
if (opts.replyTo) {
|
||||
body.replyTo = opts.replyTo;
|
||||
|
||||
@@ -133,13 +133,20 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId }) => {
|
||||
const core = getNostrRuntime();
|
||||
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const bus = activeBuses.get(aid);
|
||||
if (!bus) {
|
||||
throw new Error(`Nostr bus not running for account ${aid}`);
|
||||
}
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: core.config.loadConfig(),
|
||||
channel: "nostr",
|
||||
accountId: aid,
|
||||
});
|
||||
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||
const normalizedTo = normalizePubkey(to);
|
||||
await bus.sendDm(normalizedTo, text);
|
||||
await bus.sendDm(normalizedTo, message);
|
||||
return { channel: "nostr", to: normalizedTo };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
@@ -63,6 +63,9 @@ export const NostrConfigSchema = z.object({
|
||||
/** Whether this channel is enabled */
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
/** Markdown formatting overrides (tables). */
|
||||
markdown: MarkdownConfigSchema,
|
||||
|
||||
/** Private key in hex or nsec bech32 format */
|
||||
privateKey: z.string().optional(),
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -5,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
const zaloAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
botToken: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
@@ -578,6 +578,12 @@ async function processMessageWithPipeline(params: {
|
||||
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({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
@@ -591,6 +597,7 @@ async function processMessageWithPipeline(params: {
|
||||
core,
|
||||
statusSink,
|
||||
fetcher,
|
||||
tableMode,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
@@ -608,8 +615,11 @@ async function deliverZaloReply(params: {
|
||||
core: ZaloCoreRuntime;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
@@ -620,7 +630,7 @@ async function deliverZaloReply(params: {
|
||||
if (mediaList.length > 0) {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? payload.text : undefined;
|
||||
const caption = first ? text : undefined;
|
||||
first = false;
|
||||
try {
|
||||
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
||||
@@ -632,8 +642,8 @@ async function deliverZaloReply(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.text) {
|
||||
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
|
||||
if (text) {
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -10,6 +11,7 @@ const groupConfigSchema = z.object({
|
||||
const zalouserAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
profile: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import type {
|
||||
@@ -332,6 +332,11 @@ async function processMessage(
|
||||
runtime,
|
||||
core,
|
||||
statusSink,
|
||||
tableMode: core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "zalouser",
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
@@ -351,8 +356,11 @@ async function deliverZalouserReply(params: {
|
||||
runtime: RuntimeEnv;
|
||||
core: ZalouserCoreRuntime;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
@@ -363,7 +371,7 @@ async function deliverZalouserReply(params: {
|
||||
if (mediaList.length > 0) {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? payload.text : undefined;
|
||||
const caption = first ? text : undefined;
|
||||
first = false;
|
||||
try {
|
||||
logVerbose(core, runtime, `Sending media to ${chatId}`);
|
||||
@@ -380,8 +388,8 @@ async function deliverZalouserReply(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.text) {
|
||||
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
|
||||
if (text) {
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
|
||||
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
describe("agents_list", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock("../media/image-ops.js", () => ({
|
||||
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
|
||||
}));
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
describe("nodes camera_snap", () => {
|
||||
|
||||
@@ -75,6 +75,7 @@ vi.mock("../infra/provider-usage.js", () => ({
|
||||
formatUsageSummaryLine: () => null,
|
||||
}));
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
describe("session_status tool", () => {
|
||||
|
||||
@@ -4,9 +4,6 @@ const callGatewayMock = vi.fn();
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
vi.mock("../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
@@ -23,6 +20,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
|
||||
|
||||
+1
@@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
+1
@@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
+1
@@ -21,6 +21,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
|
||||
|
||||
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
const defaultTools = createClawdbotCodingTools();
|
||||
|
||||
+1
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
const defaultTools = createClawdbotCodingTools();
|
||||
|
||||
+1
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
|
||||
+1
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { createSandboxedReadTool } from "./pi-tools.read.js";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { acquireSessionWriteLock } from "./session-write-lock.js";
|
||||
|
||||
describe("acquireSessionWriteLock", () => {
|
||||
it("reuses locks across symlinked session paths", async () => {
|
||||
if (process.platform === "win32") {
|
||||
expect(true).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
||||
try {
|
||||
const realDir = path.join(root, "real");
|
||||
const linkDir = path.join(root, "link");
|
||||
await fs.mkdir(realDir, { recursive: true });
|
||||
await fs.symlink(realDir, linkDir);
|
||||
|
||||
const sessionReal = path.join(realDir, "sessions.json");
|
||||
const sessionLink = path.join(linkDir, "sessions.json");
|
||||
|
||||
const lockA = await acquireSessionWriteLock({ sessionFile: sessionReal, timeoutMs: 500 });
|
||||
const lockB = await acquireSessionWriteLock({ sessionFile: sessionLink, timeoutMs: 500 });
|
||||
|
||||
await lockB.release();
|
||||
await lockA.release();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,20 +45,28 @@ export async function acquireSessionWriteLock(params: {
|
||||
}> {
|
||||
const timeoutMs = params.timeoutMs ?? 10_000;
|
||||
const staleMs = params.staleMs ?? 30 * 60 * 1000;
|
||||
const sessionFile = params.sessionFile;
|
||||
const lockPath = `${sessionFile}.lock`;
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||
const sessionFile = path.resolve(params.sessionFile);
|
||||
const sessionDir = path.dirname(sessionFile);
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
let normalizedDir = sessionDir;
|
||||
try {
|
||||
normalizedDir = await fs.realpath(sessionDir);
|
||||
} catch {
|
||||
// Fall back to the resolved path if realpath fails (permissions, transient FS).
|
||||
}
|
||||
const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile));
|
||||
const lockPath = `${normalizedSessionFile}.lock`;
|
||||
|
||||
const held = HELD_LOCKS.get(sessionFile);
|
||||
const held = HELD_LOCKS.get(normalizedSessionFile);
|
||||
if (held) {
|
||||
held.count += 1;
|
||||
return {
|
||||
release: async () => {
|
||||
const current = HELD_LOCKS.get(sessionFile);
|
||||
const current = HELD_LOCKS.get(normalizedSessionFile);
|
||||
if (!current) return;
|
||||
current.count -= 1;
|
||||
if (current.count > 0) return;
|
||||
HELD_LOCKS.delete(sessionFile);
|
||||
HELD_LOCKS.delete(normalizedSessionFile);
|
||||
await current.handle.close();
|
||||
await fs.rm(current.lockPath, { force: true });
|
||||
},
|
||||
@@ -75,14 +83,14 @@ export async function acquireSessionWriteLock(params: {
|
||||
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath });
|
||||
HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath });
|
||||
return {
|
||||
release: async () => {
|
||||
const current = HELD_LOCKS.get(sessionFile);
|
||||
const current = HELD_LOCKS.get(normalizedSessionFile);
|
||||
if (!current) return;
|
||||
current.count -= 1;
|
||||
if (current.count > 0) return;
|
||||
HELD_LOCKS.delete(sessionFile);
|
||||
HELD_LOCKS.delete(normalizedSessionFile);
|
||||
await current.handle.close();
|
||||
await fs.rm(current.lockPath, { force: true });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
const stubTool = (name: string) => ({
|
||||
name,
|
||||
description: `${name} stub`,
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("../tools/image-tool.js", () => ({
|
||||
createImageTool: () => stubTool("image"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/web-tools.js", () => ({
|
||||
createWebSearchTool: () => null,
|
||||
createWebFetchTool: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
getPluginToolMeta: () => undefined,
|
||||
}));
|
||||
@@ -0,0 +1,30 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
const stubTool = (name: string) => ({
|
||||
name,
|
||||
description: `${name} stub`,
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("../tools/browser-tool.js", () => ({
|
||||
createBrowserTool: () => stubTool("browser"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/canvas-tool.js", () => ({
|
||||
createCanvasTool: () => stubTool("canvas"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/image-tool.js", () => ({
|
||||
createImageTool: () => stubTool("image"),
|
||||
}));
|
||||
|
||||
vi.mock("../tools/web-tools.js", () => ({
|
||||
createWebSearchTool: () => null,
|
||||
createWebFetchTool: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/tools.js", () => ({
|
||||
resolvePluginTools: () => [],
|
||||
getPluginToolMeta: () => undefined,
|
||||
}));
|
||||
@@ -1,122 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveCommandAuthorization } from "./command-auth.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
|
||||
describe("resolveCommandAuthorization", () => {
|
||||
it("falls back from empty SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: "",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back from whitespace SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: " ",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+999"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: " ",
|
||||
SenderE164: " ",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+999");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back from un-normalizable SenderId to SenderE164", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+123"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+999",
|
||||
SenderId: "wat",
|
||||
SenderE164: "+123",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+123");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const ctx = {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:120363401234567890@g.us",
|
||||
SenderId: "123@lid",
|
||||
SenderE164: "+41796666864",
|
||||
} as MsgContext;
|
||||
|
||||
const auth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(auth.senderId).toBe("+41796666864");
|
||||
expect(auth.isAuthorizedSender).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import 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 { listChatCommands } from "./commands-registry.js";
|
||||
import { parseActivationCommand } from "./group-activation.js";
|
||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
@@ -14,6 +18,123 @@ afterEach(() => {
|
||||
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", () => {
|
||||
it("requires slash for send policy", () => {
|
||||
expect(parseSendPolicyCommand("/send on")).toEqual({
|
||||
@@ -1,156 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
serializeCommandArgs,
|
||||
} from "./commands-registry.js";
|
||||
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
||||
|
||||
describe("commands registry args", () => {
|
||||
it("parses positional args and captureRemaining", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "debug",
|
||||
description: "debug",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{ name: "action", description: "action", type: "string" },
|
||||
{ name: "path", description: "path", type: "string" },
|
||||
{ name: "value", description: "value", type: "string", captureRemaining: true },
|
||||
],
|
||||
};
|
||||
|
||||
const args = parseCommandArgs(command, "set foo bar baz");
|
||||
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
|
||||
});
|
||||
|
||||
it("serializes args via raw first, then values", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
description: "model",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
|
||||
};
|
||||
|
||||
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
|
||||
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"gpt-5.2-codex",
|
||||
);
|
||||
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"/model gpt-5.2-codex",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves auto arg menus when missing a choice arg", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "usage",
|
||||
description: "usage",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("mode");
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
||||
});
|
||||
|
||||
it("does not show menus when arg already provided", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "usage",
|
||||
description: "usage",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { values: { mode: "tokens" } },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves function-based choices with a default provider/model context", () => {
|
||||
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
|
||||
null;
|
||||
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "think",
|
||||
description: "think",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "level",
|
||||
description: "level",
|
||||
type: "string",
|
||||
choices: ({ provider, model, command, arg }) => {
|
||||
seen = { provider, model, commandKey: command.key, argName: arg.name };
|
||||
return ["low", "high"];
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("level");
|
||||
expect(menu?.choices).toEqual(["low", "high"]);
|
||||
expect(seen?.commandKey).toBe("think");
|
||||
expect(seen?.argName).toBe("level");
|
||||
expect(seen?.provider).toBeTruthy();
|
||||
expect(seen?.model).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show menus when args were provided as raw text only", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "usage",
|
||||
description: "usage",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "none",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { raw: "on" },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,19 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCommandText,
|
||||
buildCommandTextFromArgs,
|
||||
getCommandDetection,
|
||||
listChatCommands,
|
||||
listChatCommandsForConfig,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
normalizeCommandBody,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
serializeCommandArgs,
|
||||
shouldHandleTextCommands,
|
||||
} from "./commands-registry.js";
|
||||
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
@@ -154,3 +159,150 @@ describe("commands registry", () => {
|
||||
expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram");
|
||||
});
|
||||
});
|
||||
|
||||
describe("commands registry args", () => {
|
||||
it("parses positional args and captureRemaining", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "debug",
|
||||
description: "debug",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{ name: "action", description: "action", type: "string" },
|
||||
{ name: "path", description: "path", type: "string" },
|
||||
{ name: "value", description: "value", type: "string", captureRemaining: true },
|
||||
],
|
||||
};
|
||||
|
||||
const args = parseCommandArgs(command, "set foo bar baz");
|
||||
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
|
||||
});
|
||||
|
||||
it("serializes args via raw first, then values", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
description: "model",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsParsing: "positional",
|
||||
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
|
||||
};
|
||||
|
||||
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
|
||||
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"gpt-5.2-codex",
|
||||
);
|
||||
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
|
||||
"/model gpt-5.2-codex",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves auto arg menus when missing a choice arg", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "usage",
|
||||
description: "usage",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("mode");
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
||||
});
|
||||
|
||||
it("does not show menus when arg already provided", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "usage",
|
||||
description: "usage",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { values: { mode: "tokens" } },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves function-based choices with a default provider/model context", () => {
|
||||
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
|
||||
null;
|
||||
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "think",
|
||||
description: "think",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "positional",
|
||||
args: [
|
||||
{
|
||||
name: "level",
|
||||
description: "level",
|
||||
type: "string",
|
||||
choices: ({ provider, model, command, arg }) => {
|
||||
seen = { provider, model, commandKey: command.key, argName: arg.name };
|
||||
return ["low", "high"];
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("level");
|
||||
expect(menu?.choices).toEqual(["low", "high"]);
|
||||
expect(seen?.commandKey).toBe("think");
|
||||
expect(seen?.argName).toBe("level");
|
||||
expect(seen?.provider).toBeTruthy();
|
||||
expect(seen?.model).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show menus when args were provided as raw text only", () => {
|
||||
const command: ChatCommandDefinition = {
|
||||
key: "usage",
|
||||
description: "usage",
|
||||
textAliases: [],
|
||||
scope: "both",
|
||||
argsMenu: "auto",
|
||||
argsParsing: "none",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({
|
||||
command,
|
||||
args: { raw: "on" },
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createInboundDebouncer } from "./inbound-debounce.js";
|
||||
|
||||
describe("createInboundDebouncer", () => {
|
||||
it("debounces and combines items", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
||||
debounceMs: 10,
|
||||
buildKey: (item) => item.key,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1" });
|
||||
await debouncer.enqueue({ key: "a", id: "2" });
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(calls).toEqual([["1", "2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("flushes buffered items before non-debounced item", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
||||
debounceMs: 50,
|
||||
buildKey: (item) => item.key,
|
||||
shouldDebounce: (item) => item.debounce,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
||||
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
||||
|
||||
expect(calls).toEqual([["1"], ["2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { GroupKeyResolution } from "../config/sessions.js";
|
||||
import { createInboundDebouncer } from "./inbound-debounce.js";
|
||||
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";
|
||||
import { finalizeInboundContext } from "./reply/inbound-context.js";
|
||||
import {
|
||||
buildInboundDedupeKey,
|
||||
resetInboundDedupe,
|
||||
shouldSkipDuplicateInbound,
|
||||
} from "./reply/inbound-dedupe.js";
|
||||
import { formatInboundBodyWithSenderMeta } from "./reply/inbound-sender-meta.js";
|
||||
import { normalizeInboundTextNewlines } from "./reply/inbound-text.js";
|
||||
import { resolveGroupRequireMention } from "./reply/groups.js";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
normalizeMentionText,
|
||||
} from "./reply/mentions.js";
|
||||
import { initSessionState } from "./reply/session.js";
|
||||
|
||||
describe("applyTemplate", () => {
|
||||
it("renders primitive values", () => {
|
||||
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
|
||||
const overrides = ctx as Record<string, unknown>;
|
||||
overrides.MessageSid = 42;
|
||||
overrides.IsNewSession = true;
|
||||
|
||||
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
|
||||
});
|
||||
|
||||
it("renders arrays of primitives", () => {
|
||||
const ctx = { MediaPaths: ["a"] } as TemplateContext;
|
||||
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
|
||||
|
||||
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
|
||||
});
|
||||
|
||||
it("drops object values", () => {
|
||||
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
|
||||
|
||||
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
|
||||
});
|
||||
|
||||
it("renders missing placeholders as empty", () => {
|
||||
const ctx: TemplateContext = {};
|
||||
|
||||
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInboundTextNewlines", () => {
|
||||
it("keeps real newlines", () => {
|
||||
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("normalizes CRLF/CR to LF", () => {
|
||||
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
|
||||
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("decodes literal \\n to newlines when no real newlines exist", () => {
|
||||
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("finalizeInboundContext", () => {
|
||||
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
|
||||
const ctx: MsgContext = {
|
||||
Body: "a\\nb\r\nc",
|
||||
RawBody: "raw\\nline",
|
||||
ChatType: "channel",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
GroupSubject: "Test",
|
||||
};
|
||||
|
||||
const out = finalizeInboundContext(ctx);
|
||||
expect(out.Body).toBe("a\nb\nc");
|
||||
expect(out.RawBody).toBe("raw\nline");
|
||||
expect(out.BodyForAgent).toBe("a\nb\nc");
|
||||
expect(out.BodyForCommands).toBe("raw\nline");
|
||||
expect(out.CommandAuthorized).toBe(false);
|
||||
expect(out.ChatType).toBe("channel");
|
||||
expect(out.ConversationLabel).toContain("Test");
|
||||
});
|
||||
|
||||
it("can force BodyForCommands to follow updated CommandBody", () => {
|
||||
const ctx: MsgContext = {
|
||||
Body: "base",
|
||||
BodyForCommands: "<media:audio>",
|
||||
CommandBody: "say hi",
|
||||
From: "signal:+15550001111",
|
||||
ChatType: "direct",
|
||||
};
|
||||
|
||||
finalizeInboundContext(ctx, { forceBodyForCommands: true });
|
||||
expect(ctx.BodyForCommands).toBe("say hi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInboundBodyWithSenderMeta", () => {
|
||||
it("does nothing for direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
|
||||
});
|
||||
|
||||
it("appends a sender meta line for non-direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers SenderE164 in the label when present", () => {
|
||||
const ctx: MsgContext = {
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderId: "bob@s.whatsapp.net",
|
||||
SenderE164: "+222",
|
||||
};
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
|
||||
"[X] hi\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends with a real newline even if the body contains literal \\n", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
|
||||
"[X] one\\n[X] two\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate a sender meta line when one is already present", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not append when the body already includes a sender prefix", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
|
||||
});
|
||||
|
||||
it("does not append when the sender prefix follows an envelope header", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
|
||||
"[Signal Group] Alice (A1): hi",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inbound dedupe", () => {
|
||||
it("builds a stable key when MessageSid is present", () => {
|
||||
const ctx: MsgContext = {
|
||||
Provider: "telegram",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:123",
|
||||
MessageSid: "42",
|
||||
};
|
||||
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
|
||||
});
|
||||
|
||||
it("skips duplicates with the same key", () => {
|
||||
resetInboundDedupe();
|
||||
const ctx: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+1555",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
|
||||
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not dedupe when the peer changes", () => {
|
||||
resetInboundDedupe();
|
||||
const base: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not dedupe across session keys", () => {
|
||||
resetInboundDedupe();
|
||||
const base: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
OriginatingChannel: "whatsapp",
|
||||
OriginatingTo: "whatsapp:+1555",
|
||||
MessageSid: "msg-1",
|
||||
};
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createInboundDebouncer", () => {
|
||||
it("debounces and combines items", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
||||
debounceMs: 10,
|
||||
buildKey: (item) => item.key,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1" });
|
||||
await debouncer.enqueue({ key: "a", id: "2" });
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(calls).toEqual([["1", "2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("flushes buffered items before non-debounced item", async () => {
|
||||
vi.useFakeTimers();
|
||||
const calls: Array<string[]> = [];
|
||||
|
||||
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
||||
debounceMs: 50,
|
||||
buildKey: (item) => item.key,
|
||||
shouldDebounce: (item) => item.debounce,
|
||||
onFlush: async (items) => {
|
||||
calls.push(items.map((entry) => entry.id));
|
||||
},
|
||||
});
|
||||
|
||||
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
||||
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
||||
|
||||
expect(calls).toEqual([["1"], ["2"]]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSessionState sender meta", () => {
|
||||
it("injects sender meta into BodyStripped for group chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp 123@g.us] ping",
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SenderId: "222@s.whatsapp.net",
|
||||
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("does not inject sender meta for direct chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp +1] ping",
|
||||
ChatType: "direct",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SessionKey: "agent:main:whatsapp:dm:+222",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mention helpers", () => {
|
||||
it("builds regexes and skips invalid patterns", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
|
||||
},
|
||||
});
|
||||
expect(regexes).toHaveLength(1);
|
||||
expect(regexes[0]?.test("clawd")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes zero-width characters", () => {
|
||||
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
|
||||
});
|
||||
|
||||
it("matches patterns case-insensitively", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
|
||||
});
|
||||
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses per-agent mention patterns when configured", () => {
|
||||
const regexes = buildMentionRegexes(
|
||||
{
|
||||
messages: {
|
||||
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
|
||||
},
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "work",
|
||||
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"work",
|
||||
);
|
||||
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
|
||||
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGroupRequireMention", () => {
|
||||
it("respects Discord guild/channel requireMention settings", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
guilds: {
|
||||
"145": {
|
||||
requireMention: false,
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Provider: "discord",
|
||||
From: "discord:group:123",
|
||||
GroupChannel: "#general",
|
||||
GroupSpace: "145",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
channel: "discord",
|
||||
id: "123",
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
|
||||
it("respects Slack channel requireMention settings", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
channels: {
|
||||
C123: { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const ctx: TemplateContext = {
|
||||
Provider: "slack",
|
||||
From: "slack:channel:C123",
|
||||
GroupSubject: "#general",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
channel: "slack",
|
||||
id: "C123",
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
|
||||
});
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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.
+42
-137
@@ -2,174 +2,79 @@ import fs from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { MsgContext, TemplateContext } from "./templating.js";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
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),
|
||||
const sandboxMocks = vi.hoisted(() => ({
|
||||
ensureSandboxWorkspaceForSession: vi.fn(),
|
||||
}));
|
||||
|
||||
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("../agents/sandbox.js", () => sandboxMocks);
|
||||
|
||||
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 { 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);
|
||||
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
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") },
|
||||
};
|
||||
return withTempHomeBase(async (home) => await fn(home), { prefix: "clawdbot-triggers-" });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("trigger handling", () => {
|
||||
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => {
|
||||
describe("stageSandboxMedia", () => {
|
||||
it("stages inbound media into the sandbox workspace", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const inboundDir = join(home, ".clawdbot", "media", "inbound");
|
||||
await fs.mkdir(inboundDir, { recursive: true });
|
||||
const mediaPath = join(inboundDir, "photo.jpg");
|
||||
await fs.writeFile(mediaPath, "test");
|
||||
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
const sandboxDir = join(home, "sandboxes", "session");
|
||||
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
|
||||
workspaceDir: sandboxDir,
|
||||
containerWorkdir: "/work",
|
||||
});
|
||||
|
||||
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 = {
|
||||
const ctx: MsgContext = {
|
||||
Body: "hi",
|
||||
From: "whatsapp:group:demo",
|
||||
To: "+2000",
|
||||
ChatType: "group" as const,
|
||||
Provider: "whatsapp" as const,
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
MediaPath: mediaPath,
|
||||
MediaType: "image/jpeg",
|
||||
MediaUrl: mediaPath,
|
||||
};
|
||||
const sessionCtx: TemplateContext = { ...ctx };
|
||||
|
||||
const res = await getReplyFromConfig(ctx, {}, cfg);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("ok");
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||
|
||||
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
|
||||
const stagedPath = `media/inbound/${basename(mediaPath)}`;
|
||||
expect(prompt).toContain(stagedPath);
|
||||
expect(prompt).not.toContain(mediaPath);
|
||||
|
||||
const sessionKey = resolveSessionKey(
|
||||
cfg.session?.scope ?? "per-sender",
|
||||
await stageSandboxMedia({
|
||||
ctx,
|
||||
cfg.session?.mainKey,
|
||||
);
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const sandbox = await ensureSandboxWorkspaceForSession({
|
||||
config: cfg,
|
||||
sessionKey,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
sessionCtx,
|
||||
cfg: {
|
||||
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"),
|
||||
});
|
||||
expect(sandbox).not.toBeNull();
|
||||
if (!sandbox) {
|
||||
throw new Error("Expected sandbox to be set");
|
||||
}
|
||||
const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath));
|
||||
|
||||
const stagedPath = `media/inbound/${basename(mediaPath)}`;
|
||||
expect(ctx.MediaPath).toBe(stagedPath);
|
||||
expect(sessionCtx.MediaPath).toBe(stagedPath);
|
||||
expect(ctx.MediaUrl).toBe(stagedPath);
|
||||
expect(sessionCtx.MediaUrl).toBe(stagedPath);
|
||||
|
||||
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(mediaPath));
|
||||
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
File renamed without changes.
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseAudioTag } from "./audio-tags.js";
|
||||
|
||||
describe("parseAudioTag", () => {
|
||||
it("detects audio_as_voice and strips the tag", () => {
|
||||
const result = parseAudioTag("Hello [[audio_as_voice]] world");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.hadTag).toBe(true);
|
||||
expect(result.text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("returns empty output for missing text", () => {
|
||||
const result = parseAudioTag(undefined);
|
||||
expect(result.audioAsVoice).toBe(false);
|
||||
expect(result.hadTag).toBe(false);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("removes tag-only messages", () => {
|
||||
const result = parseAudioTag("[[audio_as_voice]]");
|
||||
expect(result.audioAsVoice).toBe(true);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||
|
||||
describe("block reply coalescer", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("coalesces chunks within the idle window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hello" });
|
||||
coalescer.enqueue({ text: "world" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(flushes).toEqual(["Hello world"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("waits until minChars before idle flush", async () => {
|
||||
vi.useFakeTimers();
|
||||
const flushes: string[] = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push(payload.text ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "short" });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual([]);
|
||||
|
||||
coalescer.enqueue({ text: "message" });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual(["short message"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes buffered text before media payloads", () => {
|
||||
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
|
||||
const coalescer = createBlockReplyCoalescer({
|
||||
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
|
||||
shouldAbort: () => false,
|
||||
onFlush: (payload) => {
|
||||
flushes.push({
|
||||
text: payload.text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "Hello" });
|
||||
coalescer.enqueue({ text: "world" });
|
||||
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
|
||||
void coalescer.flush({ force: true });
|
||||
|
||||
expect(flushes[0].text).toBe("Hello world");
|
||||
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/config.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||
"../../pairing/pairing-store.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||
"../../channels/plugins/pairing.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listPairingChannels: () => ["telegram"],
|
||||
};
|
||||
});
|
||||
|
||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||
const ctx = {
|
||||
Body: commandBody,
|
||||
CommandBody: commandBody,
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
...ctxOverrides,
|
||||
} as MsgContext;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives: parseInlineDirectives(commandBody),
|
||||
elevated: { enabled: true, allowed: true, failures: [] },
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp",
|
||||
defaultGroupActivation: () => "mention",
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "telegram",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleCommands /allowlist", () => {
|
||||
it("lists config + store allowFrom entries", async () => {
|
||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist list dm", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Channel: telegram");
|
||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||
});
|
||||
|
||||
it("adds entries to config and pairing store", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: ["123", "789"],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist add dm 789", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||
}),
|
||||
);
|
||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
});
|
||||
expect(result.reply?.text).toContain("DM allowlist added");
|
||||
});
|
||||
});
|
||||
@@ -1,57 +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 { 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
+98
-4
@@ -5,6 +5,47 @@ 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"],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(async () => [
|
||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
|
||||
@@ -46,17 +87,70 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
contextTokens: 16000,
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/models command", () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
// allowlist is empty => allowAny, but still okay for listing
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
} as unknown as ClawdbotConfig;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
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 } });
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
+54
-2
@@ -5,8 +5,12 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
import { handleDirectiveOnly } from "./directive-handling.impl.js";
|
||||
import {
|
||||
maybeHandleModelDirectiveInfo,
|
||||
resolveModelSelectionFromDirective,
|
||||
} from "./directive-handling.model.js";
|
||||
|
||||
// Mock dependencies
|
||||
// Mock dependencies for directive handling persistence.
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentConfig: vi.fn(() => ({})),
|
||||
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
||||
@@ -36,6 +40,55 @@ function baseConfig(): 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)", () => {
|
||||
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
|
||||
const allowedModelCatalog = [
|
||||
@@ -106,7 +159,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
formatModelSwitchEvent: (label) => `Switched to ${label}`,
|
||||
});
|
||||
|
||||
// No model directive = no model message
|
||||
expect(result?.text ?? "").not.toContain("Model set to");
|
||||
expect(result?.text ?? "").not.toContain("failed");
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
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,5 +1,9 @@
|
||||
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";
|
||||
|
||||
@@ -57,6 +61,79 @@ const baseQueuedRun = (messageProvider = "whatsapp"): 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", () => {
|
||||
it("drops payloads already sent via messaging tool", async () => {
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
@@ -0,0 +1,185 @@
|
||||
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,6 +28,7 @@ export async function getReplyFromConfig(
|
||||
opts?: GetReplyOptions,
|
||||
configOverride?: ClawdbotConfig,
|
||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1";
|
||||
const cfg = configOverride ?? loadConfig();
|
||||
const targetSessionKey =
|
||||
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
|
||||
@@ -62,7 +63,7 @@ export async function getReplyFromConfig(
|
||||
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDirRaw,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
|
||||
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
|
||||
});
|
||||
const workspaceDir = workspace.dir;
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
@@ -81,12 +82,14 @@ export async function getReplyFromConfig(
|
||||
|
||||
const finalized = finalizeInboundContext(ctx);
|
||||
|
||||
await applyMediaUnderstanding({
|
||||
ctx: finalized,
|
||||
cfg,
|
||||
agentDir,
|
||||
activeModel: { provider, model },
|
||||
});
|
||||
if (!isFastTestEnv) {
|
||||
await applyMediaUnderstanding({
|
||||
ctx: finalized,
|
||||
cfg,
|
||||
agentDir,
|
||||
activeModel: { provider, model },
|
||||
});
|
||||
}
|
||||
|
||||
const commandAuthorized = finalized.CommandAuthorized;
|
||||
resolveCommandAuthorization({
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
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