Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504f2b0606 | ||
|
|
e10e81f579 | ||
|
|
c5239f6a8e | ||
|
|
cccd7c7b8e | ||
|
|
8f1132e8ec | ||
|
|
e6477363e9 | ||
|
|
1a4fc8dea6 | ||
|
|
a3daf3d115 | ||
|
|
f3f80509e3 | ||
|
|
7cebe7a506 | ||
|
|
5986175268 | ||
|
|
7630c6dccb | ||
|
|
d63cc1e8a7 | ||
|
|
80bb6b712c | ||
|
|
410b8f223e | ||
|
|
693f152895 | ||
|
|
78a4441ac2 | ||
|
|
c92265a51b | ||
|
|
d5fdda8e28 | ||
|
|
6d969fe58e | ||
|
|
68c7d577a4 | ||
|
|
1ea8917e2b | ||
|
|
07c93dfd30 | ||
|
|
cf0ea6c756 | ||
|
|
8c9e32c4a3 | ||
|
|
34d59d7913 | ||
|
|
0c0d9e1d22 | ||
|
|
2ee45d50a4 | ||
|
|
0c0e1e4226 | ||
|
|
e7953d8164 | ||
|
|
f42b12646d |
No files matched your search
+6
-2
@@ -7,7 +7,7 @@
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
|
||||
- Hooks: add internal hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
|
||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
@@ -15,7 +15,7 @@
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** drop legacy target normalization helpers; use outbound target normalization and resolver flows.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; internal hooks live under `clawdbot hooks`.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`.
|
||||
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
@@ -27,6 +27,7 @@
|
||||
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
|
||||
- Tools: add `process submit` helper to send CR for PTY sessions.
|
||||
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
|
||||
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
|
||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||
- UI: allow deleting sessions from the Control UI.
|
||||
@@ -43,9 +44,11 @@
|
||||
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
||||
|
||||
### Fixes
|
||||
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
|
||||
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
|
||||
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
|
||||
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
|
||||
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
|
||||
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
|
||||
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
|
||||
@@ -58,6 +61,7 @@
|
||||
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
|
||||
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
|
||||
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
|
||||
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
|
||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
||||
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
|
||||
|
||||
+11
-11
@@ -1,16 +1,16 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot hooks` (internal hooks)"
|
||||
summary: "CLI reference for `clawdbot hooks` (agent hooks)"
|
||||
read_when:
|
||||
- You want to manage internal agent hooks
|
||||
- You want to install or update internal hooks
|
||||
- You want to manage agent hooks
|
||||
- You want to install or update hooks
|
||||
---
|
||||
|
||||
# `clawdbot hooks`
|
||||
|
||||
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
|
||||
Related:
|
||||
- Internal Hooks: [Internal Agent Hooks](/internal-hooks)
|
||||
- Hooks: [Hooks](/hooks)
|
||||
|
||||
## List All Hooks
|
||||
|
||||
@@ -18,7 +18,7 @@ Related:
|
||||
clawdbot hooks list
|
||||
```
|
||||
|
||||
List all discovered internal hooks from workspace, managed, and bundled directories.
|
||||
List all discovered hooks from workspace, managed, and bundled directories.
|
||||
|
||||
**Options:**
|
||||
- `--eligible`: Show only eligible hooks (requirements met)
|
||||
@@ -28,7 +28,7 @@ List all discovered internal hooks from workspace, managed, and bundled director
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Internal Hooks (2/2 ready)
|
||||
Hooks (2/2 ready)
|
||||
|
||||
Ready:
|
||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||
@@ -82,7 +82,7 @@ Details:
|
||||
Source: clawdbot-bundled
|
||||
Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md
|
||||
Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts
|
||||
Homepage: https://docs.clawd.bot/internal-hooks#session-memory
|
||||
Homepage: https://docs.clawd.bot/hooks#session-memory
|
||||
Events: command:new
|
||||
|
||||
Requirements:
|
||||
@@ -103,7 +103,7 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Internal Hooks Status
|
||||
Hooks Status
|
||||
|
||||
Total hooks: 2
|
||||
Ready: 2
|
||||
@@ -228,7 +228,7 @@ clawdbot hooks enable session-memory
|
||||
|
||||
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
|
||||
|
||||
**See:** [session-memory documentation](/internal-hooks#session-memory)
|
||||
**See:** [session-memory documentation](/hooks#session-memory)
|
||||
|
||||
### command-logger
|
||||
|
||||
@@ -255,4 +255,4 @@ cat ~/.clawdbot/logs/commands.log | jq .
|
||||
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
||||
```
|
||||
|
||||
**See:** [command-logger documentation](/internal-hooks#command-logger)
|
||||
**See:** [command-logger documentation](/hooks#command-logger)
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
summary: "Internal agent hooks: event-driven automation for commands and lifecycle events"
|
||||
summary: "Hooks: event-driven automation for commands and lifecycle events"
|
||||
read_when:
|
||||
- You want event-driven automation for /new, /reset, /stop, and agent lifecycle events
|
||||
- You want to build, install, or debug internal hooks
|
||||
- You want to build, install, or debug hooks
|
||||
---
|
||||
# Internal Agent Hooks
|
||||
# Hooks
|
||||
|
||||
Internal hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
|
||||
Hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
|
||||
|
||||
## Getting Oriented
|
||||
|
||||
Hooks are small scripts that run when something happens. There are two kinds:
|
||||
|
||||
- **Internal hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Web-based hooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
||||
|
||||
Common uses:
|
||||
- Save a memory snapshot when you reset a session
|
||||
@@ -21,11 +21,11 @@ Common uses:
|
||||
- Trigger follow-up automation when a session starts or ends
|
||||
- Write files into the agent workspace or call external APIs when events fire
|
||||
|
||||
If you can write a small TypeScript function, you can write an internal hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
|
||||
If you can write a small TypeScript function, you can write a hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
The internal hooks system allows you to:
|
||||
The hooks system allows you to:
|
||||
- Save session context to memory when `/new` is issued
|
||||
- Log all commands for auditing
|
||||
- Trigger custom automations on agent lifecycle events
|
||||
@@ -120,7 +120,7 @@ The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documenta
|
||||
---
|
||||
name: my-hook
|
||||
description: "Short description of what this hook does"
|
||||
homepage: https://docs.clawd.bot/internal-hooks#my-hook
|
||||
homepage: https://docs.clawd.bot/hooks#my-hook
|
||||
metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}}
|
||||
---
|
||||
|
||||
@@ -162,12 +162,12 @@ The `metadata.clawdbot` object supports:
|
||||
|
||||
### Handler Implementation
|
||||
|
||||
The `handler.ts` file exports an `InternalHookHandler` function:
|
||||
The `handler.ts` file exports a `HookHandler` function:
|
||||
|
||||
```typescript
|
||||
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
|
||||
import type { HookHandler } from '../../src/hooks/hooks.js';
|
||||
|
||||
const myHandler: InternalHookHandler = async (event) => {
|
||||
const myHandler: HookHandler = async (event) => {
|
||||
// Only trigger on 'new' command
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
@@ -260,9 +260,9 @@ This hook does something useful when you issue `/new`.
|
||||
### 4. Create handler.ts
|
||||
|
||||
```typescript
|
||||
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
|
||||
import type { HookHandler } from '../../src/hooks/hooks.js';
|
||||
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
}
|
||||
@@ -505,12 +505,12 @@ Hooks run during command processing. Keep them lightweight:
|
||||
|
||||
```typescript
|
||||
// ✓ Good - async work, returns immediately
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
void processInBackground(event); // Fire and forget
|
||||
};
|
||||
|
||||
// ✗ Bad - blocks command processing
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
await slowDatabaseQuery(event);
|
||||
await evenSlowerAPICall(event);
|
||||
};
|
||||
@@ -521,7 +521,7 @@ const handler: InternalHookHandler = async (event) => {
|
||||
Always wrap risky operations:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
try {
|
||||
await riskyOperation(event);
|
||||
} catch (err) {
|
||||
@@ -536,7 +536,7 @@ const handler: InternalHookHandler = async (event) => {
|
||||
Return early if the event isn't relevant:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
// Only handle 'new' commands
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
@@ -584,7 +584,7 @@ clawdbot hooks list --verbose
|
||||
In your handler, log when it's called:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
console.log('[my-handler] Triggered:', event.type, event.action);
|
||||
// Your logic
|
||||
};
|
||||
@@ -620,11 +620,11 @@ Test your handlers in isolation:
|
||||
|
||||
```typescript
|
||||
import { test } from 'vitest';
|
||||
import { createInternalHookEvent } from './src/hooks/internal-hooks.js';
|
||||
import { createHookEvent } from './src/hooks/hooks.js';
|
||||
import myHandler from './hooks/my-hook/handler.js';
|
||||
|
||||
test('my handler works', async () => {
|
||||
const event = createInternalHookEvent('command', 'new', 'test-session', {
|
||||
const event = createHookEvent('command', 'new', 'test-session', {
|
||||
foo: 'bar'
|
||||
});
|
||||
|
||||
@@ -256,6 +256,15 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
|
||||
}
|
||||
```
|
||||
|
||||
## Status output
|
||||
When media understanding runs, `/status` includes a short summary line:
|
||||
|
||||
```
|
||||
📎 Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)
|
||||
```
|
||||
|
||||
This shows per‑capability outcomes and the chosen provider/model when applicable.
|
||||
|
||||
## Notes
|
||||
- Understanding is **best‑effort**. Errors do not block replies.
|
||||
- Attachments are still passed to models even when understanding is disabled.
|
||||
|
||||
@@ -164,13 +164,15 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||
looksLikeTargetId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
|
||||
return trimmed.includes(":");
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
|
||||
return trimmed.includes(":");
|
||||
},
|
||||
hint: "<room|alias|user>",
|
||||
},
|
||||
targetHint: "<room|alias|user>",
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
|
||||
@@ -16,14 +16,14 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupRoom = params.groupRoom?.trim() ?? "";
|
||||
const aliases = groupRoom ? [groupRoom] : [];
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupRoom || undefined,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
if (resolved) {
|
||||
if (resolved.autoReply === true) return false;
|
||||
|
||||
@@ -379,7 +379,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
SenderId: senderId,
|
||||
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
|
||||
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
||||
GroupRoom: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
|
||||
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
|
||||
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
||||
Provider: "matrix" as const,
|
||||
Surface: "matrix" as const,
|
||||
|
||||
@@ -133,13 +133,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMSTeamsMessagingTarget,
|
||||
looksLikeTargetId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(conversation:|user:)/i.test(trimmed)) return true;
|
||||
return trimmed.includes("@thread");
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(conversation:|user:)/i.test(trimmed)) return true;
|
||||
return trimmed.includes("@thread");
|
||||
},
|
||||
hint: "<conversationId|user:ID|conversation:ID>",
|
||||
},
|
||||
targetHint: "<conversationId|user:ID|conversation:ID>",
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
|
||||
@@ -150,12 +150,14 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
actions: zaloMessageActions,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeZaloMessagingTarget,
|
||||
looksLikeTargetId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<chatId>",
|
||||
},
|
||||
targetHint: "<chatId>",
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
|
||||
@@ -218,12 +218,14 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/^(zalouser|zlu):/i, "");
|
||||
},
|
||||
looksLikeTargetId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<threadId>",
|
||||
},
|
||||
targetHint: "<threadId>",
|
||||
},
|
||||
directory: {
|
||||
self: async ({ cfg, accountId, runtime }) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ afterEach(() => {
|
||||
test("exec supports pty output", async () => {
|
||||
const tool = createExecTool({ allowBackground: false });
|
||||
const result = await tool.execute("toolcall", {
|
||||
command: "node -e 'process.stdout.write(\"ok\")'",
|
||||
command: 'node -e "process.stdout.write(String.fromCharCode(111,107))"',
|
||||
pty: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ test("process send-keys encodes Enter for pty sessions", async () => {
|
||||
const processTool = createProcessTool();
|
||||
const result = await execTool.execute("toolcall", {
|
||||
command:
|
||||
"node -e \"process.stdin.on('data', d => { process.stdout.write(d); if (d.includes(10) || d.includes(13)) process.exit(0); });\"",
|
||||
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);process.stdin.on(dataEvent,d=>{process.stdout.write(d);if(d.includes(10)||d.includes(13))process.exit(0);});"',
|
||||
pty: true,
|
||||
background: true,
|
||||
});
|
||||
@@ -45,12 +45,12 @@ test("process send-keys encodes Enter for pty sessions", async () => {
|
||||
throw new Error("PTY session did not exit after send-keys");
|
||||
});
|
||||
|
||||
test("process submit sends CR for pty sessions", async () => {
|
||||
test("process submit sends Enter for pty sessions", async () => {
|
||||
const execTool = createExecTool();
|
||||
const processTool = createProcessTool();
|
||||
const result = await execTool.execute("toolcall", {
|
||||
command:
|
||||
"node -e \"process.stdin.on('data', d => { if (d.includes(13)) { process.stdout.write('submitted'); process.exit(0); } });\"",
|
||||
'node -e "const dataEvent=String.fromCharCode(100,97,116,97);const submitted=String.fromCharCode(115,117,98,109,105,116,116,101,100);process.stdin.on(dataEvent,d=>{if(d.includes(10)||d.includes(13)){process.stdout.write(submitted);process.exit(0);}});"',
|
||||
pty: true,
|
||||
background: true,
|
||||
});
|
||||
@@ -64,7 +64,8 @@ test("process submit sends CR for pty sessions", async () => {
|
||||
sessionId,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
|
||||
while (Date.now() < deadline) {
|
||||
await wait(50);
|
||||
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
|
||||
const details = poll.details as { status?: string; aggregated?: string };
|
||||
|
||||
@@ -33,9 +33,7 @@ const processSchema = Type.Object({
|
||||
keys: Type.Optional(
|
||||
Type.Array(Type.String(), { description: "Key tokens to send for send-keys" }),
|
||||
),
|
||||
hex: Type.Optional(
|
||||
Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" }),
|
||||
),
|
||||
hex: Type.Optional(Type.Array(Type.String(), { description: "Hex bytes to send for send-keys" })),
|
||||
literal: Type.Optional(Type.String({ description: "Literal string for send-keys" })),
|
||||
text: Type.Optional(Type.String({ description: "Text to paste for paste" })),
|
||||
bracketed: Type.Optional(Type.Boolean({ description: "Wrap paste in bracketed mode" })),
|
||||
@@ -121,10 +119,7 @@ export function createProcessTool(
|
||||
.sort((a, b) => b.startedAt - a.startedAt)
|
||||
.map((s) => {
|
||||
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
|
||||
return `${s.sessionId} ${pad(
|
||||
s.status,
|
||||
9,
|
||||
)} ${formatDuration(s.runtimeMs)} :: ${label}`;
|
||||
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const DSR_PATTERN = /\x1b\[\??6n/g;
|
||||
const ESC = String.fromCharCode(0x1b);
|
||||
const DSR_PATTERN = new RegExp(`${ESC}\\[\\??6n`, "g");
|
||||
|
||||
export function stripDsrRequests(input: string): { cleaned: string; requests: number } {
|
||||
let requests = 0;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, encodeKeySequence, encodePaste } from "./pty-keys.js";
|
||||
import {
|
||||
BRACKETED_PASTE_END,
|
||||
BRACKETED_PASTE_START,
|
||||
encodeKeySequence,
|
||||
encodePaste,
|
||||
} from "./pty-keys.js";
|
||||
|
||||
test("encodeKeySequence maps common keys and modifiers", () => {
|
||||
const enter = encodeKeySequence({ keys: ["Enter"] });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const watchMock = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
close: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("chokidar", () => {
|
||||
return {
|
||||
default: { watch: watchMock },
|
||||
};
|
||||
});
|
||||
|
||||
describe("ensureSkillsWatcher", () => {
|
||||
it("ignores node_modules, dist, and .git by default", async () => {
|
||||
const mod = await import("./refresh.js");
|
||||
mod.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
|
||||
|
||||
expect(watchMock).toHaveBeenCalledTimes(1);
|
||||
const opts = watchMock.mock.calls[0]?.[1] as { ignored?: unknown };
|
||||
|
||||
expect(opts.ignored).toBe(mod.DEFAULT_SKILLS_WATCH_IGNORED);
|
||||
const ignored = mod.DEFAULT_SKILLS_WATCH_IGNORED;
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/dist/index.js"))).toBe(true);
|
||||
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.git/config"))).toBe(true);
|
||||
expect(ignored.some((re) => re.test("/tmp/.hidden/skills/index.md"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,12 @@ const workspaceVersions = new Map<string, number>();
|
||||
const watchers = new Map<string, SkillsWatchState>();
|
||||
let globalVersion = 0;
|
||||
|
||||
export const DEFAULT_SKILLS_WATCH_IGNORED: RegExp[] = [
|
||||
/(^|[\\/])\.git([\\/]|$)/,
|
||||
/(^|[\\/])node_modules([\\/]|$)/,
|
||||
/(^|[\\/])dist([\\/]|$)/,
|
||||
];
|
||||
|
||||
function bumpVersion(current: number): number {
|
||||
const now = Date.now();
|
||||
return now <= current ? current + 1 : now;
|
||||
@@ -125,6 +131,9 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
|
||||
stabilityThreshold: debounceMs,
|
||||
pollInterval: 100,
|
||||
},
|
||||
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
|
||||
// This watcher only needs to react to skill changes.
|
||||
ignored: DEFAULT_SKILLS_WATCH_IGNORED,
|
||||
});
|
||||
|
||||
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
|
||||
|
||||
@@ -3,6 +3,36 @@ import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
|
||||
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
|
||||
|
||||
export type SessionListDeliveryContext = {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
export type SessionListRow = {
|
||||
key: string;
|
||||
kind: SessionKind;
|
||||
channel: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
deliveryContext?: SessionListDeliveryContext;
|
||||
updatedAt?: number | null;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
contextTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
sendPolicy?: string;
|
||||
lastChannel?: string;
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
transcriptPath?: string;
|
||||
messages?: unknown[];
|
||||
};
|
||||
|
||||
function normalizeKey(value?: string) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
|
||||
@@ -17,34 +17,10 @@ import {
|
||||
resolveDisplaySessionKey,
|
||||
resolveInternalSessionKey,
|
||||
resolveMainSessionAlias,
|
||||
type SessionKind,
|
||||
type SessionListRow,
|
||||
stripToolMessages,
|
||||
} from "./sessions-helpers.js";
|
||||
|
||||
type SessionListRow = {
|
||||
key: string;
|
||||
kind: SessionKind;
|
||||
channel: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
deliveryContext?: { channel?: string; to?: string; accountId?: string };
|
||||
updatedAt?: number | null;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
contextTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
systemSent?: boolean;
|
||||
abortedLastRun?: boolean;
|
||||
sendPolicy?: string;
|
||||
lastChannel?: string;
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
transcriptPath?: string;
|
||||
messages?: unknown[];
|
||||
};
|
||||
|
||||
const SessionsListToolSchema = Type.Object({
|
||||
kinds: Type.Optional(Type.Array(Type.String())),
|
||||
limit: Type.Optional(Type.Number({ minimum: 1 })),
|
||||
@@ -174,12 +150,12 @@ export function createSessionsListTool(opts?: {
|
||||
: undefined;
|
||||
const deliveryChannel =
|
||||
typeof deliveryContext?.channel === "string" ? deliveryContext.channel : undefined;
|
||||
const deliveryTo =
|
||||
typeof deliveryContext?.to === "string" ? deliveryContext.to : undefined;
|
||||
const deliveryTo = typeof deliveryContext?.to === "string" ? deliveryContext.to : undefined;
|
||||
const deliveryAccountId =
|
||||
typeof deliveryContext?.accountId === "string" ? deliveryContext.accountId : undefined;
|
||||
const lastChannel =
|
||||
deliveryChannel ?? (typeof entry.lastChannel === "string" ? entry.lastChannel : undefined);
|
||||
deliveryChannel ??
|
||||
(typeof entry.lastChannel === "string" ? entry.lastChannel : undefined);
|
||||
const lastAccountId =
|
||||
deliveryAccountId ??
|
||||
(typeof entry.lastAccountId === "string" ? entry.lastAccountId : undefined);
|
||||
|
||||
@@ -382,6 +382,42 @@ describe("handleTelegramAction", () => {
|
||||
).rejects.toThrow(/inline buttons are limited to DMs/i);
|
||||
});
|
||||
|
||||
it("allows inline buttons in DMs with tg: prefixed targets", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { botToken: "tok", capabilities: { inlineButtons: "dm" } },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "tg:5232990709",
|
||||
content: "Choose",
|
||||
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows inline buttons in groups with topic targets", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: { botToken: "tok", capabilities: { inlineButtons: "group" } },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
await handleTelegramAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to: "telegram:group:-1001234567890:topic:456",
|
||||
content: "Choose",
|
||||
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
expect(sendMessageTelegram).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends messages with inline keyboard buttons when enabled", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -41,4 +41,69 @@ describe("buildInboundMediaNote", () => {
|
||||
});
|
||||
expect(note).toBe("[media attached: /tmp/b.png | https://example.com/b.png]");
|
||||
});
|
||||
|
||||
it("only suppresses attachments when media understanding succeeded", () => {
|
||||
const note = buildInboundMediaNote({
|
||||
MediaPaths: ["/tmp/a.png", "/tmp/b.png"],
|
||||
MediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
MediaUnderstandingDecisions: [
|
||||
{
|
||||
capability: "image",
|
||||
outcome: "skipped",
|
||||
attachments: [
|
||||
{
|
||||
attachmentIndex: 0,
|
||||
attempts: [
|
||||
{
|
||||
type: "provider",
|
||||
outcome: "skipped",
|
||||
reason: "maxBytes: too large",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(note).toBe(
|
||||
[
|
||||
"[media attached: 2 files]",
|
||||
"[media attached 1/2: /tmp/a.png | https://example.com/a.png]",
|
||||
"[media attached 2/2: /tmp/b.png | https://example.com/b.png]",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses attachments when media understanding succeeds via decisions", () => {
|
||||
const note = buildInboundMediaNote({
|
||||
MediaPaths: ["/tmp/a.png", "/tmp/b.png"],
|
||||
MediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
|
||||
MediaUnderstandingDecisions: [
|
||||
{
|
||||
capability: "image",
|
||||
outcome: "success",
|
||||
attachments: [
|
||||
{
|
||||
attachmentIndex: 0,
|
||||
attempts: [
|
||||
{
|
||||
type: "provider",
|
||||
outcome: "success",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
},
|
||||
],
|
||||
chosen: {
|
||||
type: "provider",
|
||||
outcome: "success",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(note).toBe("[media attached: /tmp/b.png | https://example.com/b.png]");
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,22 @@ function formatMediaAttachedLine(params: {
|
||||
|
||||
export function buildInboundMediaNote(ctx: MsgContext): string | undefined {
|
||||
// Attachment indices follow MediaPaths/MediaUrls ordering as supplied by the channel.
|
||||
const suppressed = new Set(
|
||||
Array.isArray(ctx.MediaUnderstanding)
|
||||
? ctx.MediaUnderstanding.map((output) => output.attachmentIndex)
|
||||
: [],
|
||||
);
|
||||
const suppressed = new Set<number>();
|
||||
if (Array.isArray(ctx.MediaUnderstanding)) {
|
||||
for (const output of ctx.MediaUnderstanding) {
|
||||
suppressed.add(output.attachmentIndex);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(ctx.MediaUnderstandingDecisions)) {
|
||||
for (const decision of ctx.MediaUnderstandingDecisions) {
|
||||
if (decision.outcome !== "success") continue;
|
||||
for (const attachment of decision.attachments) {
|
||||
if (attachment.chosen?.outcome === "success") {
|
||||
suppressed.add(attachment.attachmentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const pathsFromArray = Array.isArray(ctx.MediaPaths) ? ctx.MediaPaths : undefined;
|
||||
const paths =
|
||||
pathsFromArray && pathsFromArray.length > 0
|
||||
|
||||
@@ -68,6 +68,7 @@ export const handleStatusCommand: CommandHandler = async (params, allowTextComma
|
||||
resolveDefaultThinkingLevel: params.resolveDefaultThinkingLevel,
|
||||
isGroup: params.isGroup,
|
||||
defaultGroupActivation: params.defaultGroupActivation,
|
||||
mediaDecisions: params.ctx.MediaUnderstandingDecisions,
|
||||
});
|
||||
return { shouldContinue: false, reply };
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "..
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { CommandContext } from "./commands-types.js";
|
||||
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
|
||||
import type { MediaUnderstandingDecision } from "../../media-understanding/types.js";
|
||||
|
||||
function formatApiKeySnippet(apiKey: string): string {
|
||||
const compact = apiKey.replace(/\s+/g, "");
|
||||
@@ -105,6 +106,7 @@ export async function buildStatusReply(params: {
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
isGroup: boolean;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
mediaDecisions?: MediaUnderstandingDecision[];
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
const {
|
||||
cfg,
|
||||
@@ -200,6 +202,7 @@ export async function buildStatusReply(params: {
|
||||
dropPolicy: queueSettings.dropPolicy,
|
||||
showDetails: queueOverrides,
|
||||
},
|
||||
mediaDecisions: params.mediaDecisions,
|
||||
includeTranscriptUsage: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ describe("handleCommands identity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleCommands internal hooks", () => {
|
||||
describe("handleCommands hooks", () => {
|
||||
it("triggers hooks for /new with arguments", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
|
||||
@@ -188,6 +188,7 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
resolveDefaultThinkingLevel: async () => resolvedDefaultThinkLevel,
|
||||
isGroup,
|
||||
defaultGroupActivation: defaultActivation,
|
||||
mediaDecisions: ctx.MediaUnderstandingDecisions,
|
||||
});
|
||||
}
|
||||
typing.cleanup();
|
||||
|
||||
@@ -185,6 +185,7 @@ export async function handleInlineActions(params: {
|
||||
resolveDefaultThinkingLevel,
|
||||
isGroup,
|
||||
defaultGroupActivation: defaultActivation,
|
||||
mediaDecisions: ctx.MediaUnderstandingDecisions,
|
||||
});
|
||||
await sendInlineReply(inlineStatusReply);
|
||||
directives = { ...directives, hasStatusDirective: false };
|
||||
|
||||
@@ -297,9 +297,7 @@ export async function runPreparedReply(
|
||||
abortKey: command.abortKey,
|
||||
messageId: sessionCtx.MessageSid,
|
||||
});
|
||||
const isGroupSession =
|
||||
sessionEntry?.chatType === "group" ||
|
||||
sessionEntry?.chatType === "channel";
|
||||
const isGroupSession = sessionEntry?.chatType === "group" || sessionEntry?.chatType === "channel";
|
||||
const isMainSession = !isGroupSession && sessionKey === normalizeMainKey(sessionCfg?.mainKey);
|
||||
prefixedBodyBase = await prependSystemEvents({
|
||||
cfg,
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
const ctx: TemplateContext = {
|
||||
Provider: "discord",
|
||||
From: "group:123",
|
||||
GroupRoom: "#general",
|
||||
GroupChannel: "#general",
|
||||
GroupSpace: "145",
|
||||
};
|
||||
const groupResolution: GroupKeyResolution = {
|
||||
|
||||
@@ -16,12 +16,12 @@ export function resolveGroupRequireMention(params: {
|
||||
const channel = normalizeChannelId(rawChannel);
|
||||
if (!channel) return true;
|
||||
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
|
||||
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
|
||||
const groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim();
|
||||
const groupSpace = ctx.GroupSpace?.trim();
|
||||
const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
|
||||
cfg,
|
||||
groupId,
|
||||
groupRoom,
|
||||
groupChannel,
|
||||
groupSpace,
|
||||
accountId: ctx.AccountId,
|
||||
});
|
||||
@@ -62,13 +62,13 @@ export function buildGroupIntro(params: {
|
||||
? "Activation: always-on (you receive every group message)."
|
||||
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
|
||||
const groupId = params.sessionCtx.From?.replace(/^group:/, "");
|
||||
const groupRoom = params.sessionCtx.GroupRoom?.trim() ?? subject;
|
||||
const groupChannel = params.sessionCtx.GroupChannel?.trim() ?? subject;
|
||||
const groupSpace = params.sessionCtx.GroupSpace?.trim();
|
||||
const providerIdsLine = providerId
|
||||
? getChannelDock(providerId)?.groups?.resolveGroupIntroHint?.({
|
||||
cfg: params.cfg,
|
||||
groupId,
|
||||
groupRoom,
|
||||
groupChannel,
|
||||
groupSpace,
|
||||
accountId: params.sessionCtx.AccountId,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
applyQueueDropPolicy,
|
||||
shouldSkipQueueItem,
|
||||
} from "../../../utils/queue-helpers.js";
|
||||
import { applyQueueDropPolicy, shouldSkipQueueItem } from "../../../utils/queue-helpers.js";
|
||||
import { FOLLOWUP_QUEUES, getFollowupQueue } from "./state.js";
|
||||
import type { FollowupRun, QueueDedupeMode, QueueSettings } from "./types.js";
|
||||
|
||||
|
||||
@@ -193,11 +193,9 @@ export async function initSessionState(params: {
|
||||
|
||||
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
|
||||
// Track the originating channel/to for announce routing (subagent announce-back).
|
||||
const lastChannelRaw =
|
||||
(ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel;
|
||||
const lastChannelRaw = (ctx.OriginatingChannel as string | undefined) || baseEntry?.lastChannel;
|
||||
const lastToRaw = (ctx.OriginatingTo as string | undefined) || ctx.To || baseEntry?.lastTo;
|
||||
const lastAccountIdRaw =
|
||||
(ctx.AccountId as string | undefined) || baseEntry?.lastAccountId;
|
||||
const lastAccountIdRaw = (ctx.AccountId as string | undefined) || baseEntry?.lastAccountId;
|
||||
const deliveryFields = normalizeSessionDeliveryFields({
|
||||
deliveryContext: {
|
||||
channel: lastChannelRaw,
|
||||
@@ -242,14 +240,19 @@ export async function initSessionState(params: {
|
||||
const channel = groupResolution.channel;
|
||||
const subject = ctx.GroupSubject?.trim();
|
||||
const space = ctx.GroupSpace?.trim();
|
||||
const explicitRoom = ctx.GroupRoom?.trim();
|
||||
const explicitChannel = ctx.GroupChannel?.trim();
|
||||
const normalizedChannel = normalizeChannelId(channel);
|
||||
const isRoomProvider = Boolean(
|
||||
const isChannelProvider = Boolean(
|
||||
normalizedChannel &&
|
||||
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"),
|
||||
);
|
||||
const nextRoom =
|
||||
explicitRoom ?? (isRoomProvider && subject && subject.startsWith("#") ? subject : undefined);
|
||||
explicitChannel ??
|
||||
((groupResolution.chatType === "channel" || isChannelProvider) &&
|
||||
subject &&
|
||||
subject.startsWith("#")
|
||||
? subject
|
||||
: undefined);
|
||||
const nextSubject = nextRoom ? undefined : subject;
|
||||
sessionEntry.chatType = groupResolution.chatType ?? "group";
|
||||
sessionEntry.channel = channel;
|
||||
|
||||
@@ -90,6 +90,59 @@ describe("buildStatusMessage", () => {
|
||||
expect(text).toContain("elevated");
|
||||
});
|
||||
|
||||
it("includes media understanding decisions when present", () => {
|
||||
const text = buildStatusMessage({
|
||||
agent: { model: "anthropic/claude-opus-4-5" },
|
||||
sessionEntry: { sessionId: "media", updatedAt: 0 },
|
||||
sessionKey: "agent:main:main",
|
||||
queue: { mode: "none" },
|
||||
mediaDecisions: [
|
||||
{
|
||||
capability: "image",
|
||||
outcome: "success",
|
||||
attachments: [
|
||||
{
|
||||
attachmentIndex: 0,
|
||||
attempts: [
|
||||
{
|
||||
type: "provider",
|
||||
outcome: "success",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
},
|
||||
],
|
||||
chosen: {
|
||||
type: "provider",
|
||||
outcome: "success",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
capability: "audio",
|
||||
outcome: "skipped",
|
||||
attachments: [
|
||||
{
|
||||
attachmentIndex: 1,
|
||||
attempts: [
|
||||
{
|
||||
type: "provider",
|
||||
outcome: "skipped",
|
||||
reason: "maxBytes: too large",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const normalized = normalizeTestText(text);
|
||||
expect(normalized).toContain("Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)");
|
||||
});
|
||||
|
||||
it("does not show elevated label when session explicitly disables it", () => {
|
||||
const text = buildStatusMessage({
|
||||
agent: { model: "anthropic/claude-opus-4-5", elevatedDefault: "on" },
|
||||
|
||||
@@ -24,6 +24,7 @@ import { VERSION } from "../version.js";
|
||||
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
||||
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
||||
|
||||
type AgentConfig = Partial<NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>>;
|
||||
|
||||
@@ -52,6 +53,7 @@ type StatusArgs = {
|
||||
modelAuth?: string;
|
||||
usageLine?: string;
|
||||
queue?: QueueStatus;
|
||||
mediaDecisions?: MediaUnderstandingDecision[];
|
||||
includeTranscriptUsage?: boolean;
|
||||
now?: number;
|
||||
};
|
||||
@@ -167,6 +169,42 @@ const formatUsagePair = (input?: number | null, output?: number | null) => {
|
||||
return `🧮 Tokens: ${inputLabel} in / ${outputLabel} out`;
|
||||
};
|
||||
|
||||
const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[]) => {
|
||||
if (!decisions || decisions.length === 0) return null;
|
||||
const parts = decisions
|
||||
.map((decision) => {
|
||||
const count = decision.attachments.length;
|
||||
const countLabel = count > 1 ? ` x${count}` : "";
|
||||
if (decision.outcome === "success") {
|
||||
const chosen = decision.attachments.find((entry) => entry.chosen)?.chosen;
|
||||
const provider = chosen?.provider?.trim();
|
||||
const model = chosen?.model?.trim();
|
||||
const modelLabel = provider ? (model ? `${provider}/${model}` : provider) : null;
|
||||
return `${decision.capability}${countLabel} ok${modelLabel ? ` (${modelLabel})` : ""}`;
|
||||
}
|
||||
if (decision.outcome === "no-attachment") {
|
||||
return `${decision.capability} none`;
|
||||
}
|
||||
if (decision.outcome === "disabled") {
|
||||
return `${decision.capability} off`;
|
||||
}
|
||||
if (decision.outcome === "scope-deny") {
|
||||
return `${decision.capability} denied`;
|
||||
}
|
||||
if (decision.outcome === "skipped") {
|
||||
const reason = decision.attachments
|
||||
.flatMap((entry) => entry.attempts.map((attempt) => attempt.reason).filter(Boolean))
|
||||
.find(Boolean);
|
||||
const shortReason = reason ? reason.split(":")[0]?.trim() : undefined;
|
||||
return `${decision.capability} skipped${shortReason ? ` (${shortReason})` : ""}`;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
return `📎 Media: ${parts.join(" · ")}`;
|
||||
};
|
||||
|
||||
export function buildStatusMessage(args: StatusArgs): string {
|
||||
const now = args.now ?? Date.now();
|
||||
const entry = args.sessionEntry;
|
||||
@@ -320,12 +358,14 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const costLine = costLabel ? `💵 Cost: ${costLabel}` : null;
|
||||
const usageCostLine =
|
||||
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
|
||||
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
||||
|
||||
return [
|
||||
versionLine,
|
||||
modelLine,
|
||||
usageCostLine,
|
||||
`📚 ${contextLine}`,
|
||||
mediaLine,
|
||||
args.usageLine,
|
||||
`🧵 ${sessionLine}`,
|
||||
`⚙️ ${optionsLine}`,
|
||||
|
||||
@@ -63,7 +63,8 @@ export type MsgContext = {
|
||||
/** Human label for envelope headers (conversation label, not sender). */
|
||||
ConversationLabel?: string;
|
||||
GroupSubject?: string;
|
||||
GroupRoom?: string;
|
||||
/** Human label for channel-like group conversations (e.g. #general, #support). */
|
||||
GroupChannel?: string;
|
||||
GroupSpace?: string;
|
||||
GroupMembers?: string;
|
||||
GroupSystemPrompt?: string;
|
||||
@@ -96,7 +97,7 @@ export type MsgContext = {
|
||||
*/
|
||||
OriginatingTo?: string;
|
||||
/**
|
||||
* Messages from internal hooks to be included in the response.
|
||||
* Messages from hooks to be included in the response.
|
||||
* Used for hook confirmation messages like "Session context saved to memory".
|
||||
*/
|
||||
HookMessages?: string[];
|
||||
|
||||
@@ -90,6 +90,23 @@ function createMessageQueue(ws: WebSocket) {
|
||||
return { next };
|
||||
}
|
||||
|
||||
async function waitForListMatch<T>(
|
||||
fetchList: () => Promise<T>,
|
||||
predicate: (value: T) => boolean,
|
||||
timeoutMs = 2000,
|
||||
intervalMs = 50,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (true) {
|
||||
const value = await fetchList();
|
||||
if (predicate(value)) return value;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error("timeout waiting for list update");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
describe("chrome extension relay server", () => {
|
||||
let cdpUrl = "";
|
||||
|
||||
@@ -152,9 +169,48 @@ describe("chrome extension relay server", () => {
|
||||
const list = (await fetch(`${cdpUrl}/json/list`).then((r) => r.json())) as Array<{
|
||||
id?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
}>;
|
||||
expect(list.some((t) => t.id === "t1" && t.url === "https://example.com")).toBe(true);
|
||||
|
||||
// Simulate navigation updating tab metadata.
|
||||
ext.send(
|
||||
JSON.stringify({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.targetInfoChanged",
|
||||
params: {
|
||||
targetInfo: {
|
||||
targetId: "t1",
|
||||
type: "page",
|
||||
title: "DER STANDARD",
|
||||
url: "https://www.derstandard.at/",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const list2 = await waitForListMatch(
|
||||
async () =>
|
||||
(await fetch(`${cdpUrl}/json/list`).then((r) => r.json())) as Array<{
|
||||
id?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
}>,
|
||||
(list) =>
|
||||
list.some(
|
||||
(t) =>
|
||||
t.id === "t1" && t.url === "https://www.derstandard.at/" && t.title === "DER STANDARD",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
list2.some(
|
||||
(t) =>
|
||||
t.id === "t1" && t.url === "https://www.derstandard.at/" && t.title === "DER STANDARD",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
|
||||
await waitForOpen(cdp);
|
||||
const q = createMessageQueue(cdp);
|
||||
|
||||
@@ -497,6 +497,23 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep cached tab metadata fresh for /json/list.
|
||||
// After navigation, Chrome updates URL/title via Target.targetInfoChanged.
|
||||
if (method === "Target.targetInfoChanged") {
|
||||
const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } };
|
||||
const targetInfo = changed?.targetInfo;
|
||||
const targetId = targetInfo?.targetId;
|
||||
if (targetId && (targetInfo?.type ?? "page") === "page") {
|
||||
for (const [sid, target] of connectedTargets) {
|
||||
if (target.targetId !== targetId) continue;
|
||||
connectedTargets.set(sid, {
|
||||
...target,
|
||||
targetInfo: { ...target.targetInfo, ...(targetInfo as object) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToCdpClients({ method, params, sessionId });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -70,4 +70,3 @@ describe("resolveCommandAuthorizedFromAuthorizers", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,4 +21,3 @@ export function resolveCommandAuthorizedFromAuthorizers(params: {
|
||||
}
|
||||
return authorizers.some((entry) => entry.configured && entry.allowed);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function resolveConversationLabel(ctx: MsgContext): string | undefined {
|
||||
}
|
||||
|
||||
const base =
|
||||
ctx.GroupRoom?.trim() ||
|
||||
ctx.GroupChannel?.trim() ||
|
||||
ctx.GroupSubject?.trim() ||
|
||||
ctx.GroupSpace?.trim() ||
|
||||
ctx.From?.trim() ||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
listSlackDirectoryGroupsFromConfig,
|
||||
listSlackDirectoryPeersFromConfig,
|
||||
listTelegramDirectoryGroupsFromConfig,
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
listWhatsAppDirectoryGroupsFromConfig,
|
||||
listWhatsAppDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
|
||||
describe("directory (config-backed)", () => {
|
||||
it("lists Slack peers/groups from config", async () => {
|
||||
const { slackPlugin } = await import("./slack.js");
|
||||
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as any;
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -16,12 +24,11 @@ describe("directory (config-backed)", () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const peers = await slackPlugin.directory?.listPeers?.({
|
||||
const peers = await listSlackDirectoryPeersFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: null,
|
||||
limit: null,
|
||||
runtime,
|
||||
});
|
||||
expect(peers?.map((e) => e.id).sort()).toEqual([
|
||||
"user:u123",
|
||||
@@ -30,19 +37,16 @@ describe("directory (config-backed)", () => {
|
||||
"user:u999",
|
||||
]);
|
||||
|
||||
const groups = await slackPlugin.directory?.listGroups?.({
|
||||
const groups = await listSlackDirectoryGroupsFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: null,
|
||||
limit: null,
|
||||
runtime,
|
||||
});
|
||||
expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]);
|
||||
});
|
||||
|
||||
it("lists Discord peers/groups from config (numeric ids only)", async () => {
|
||||
const { discordPlugin } = await import("./discord.js");
|
||||
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as any;
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
@@ -63,28 +67,24 @@ describe("directory (config-backed)", () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const peers = await discordPlugin.directory?.listPeers?.({
|
||||
const peers = await listDiscordDirectoryPeersFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: null,
|
||||
limit: null,
|
||||
runtime,
|
||||
});
|
||||
expect(peers?.map((e) => e.id).sort()).toEqual(["user:111", "user:12345", "user:222"]);
|
||||
|
||||
const groups = await discordPlugin.directory?.listGroups?.({
|
||||
const groups = await listDiscordDirectoryGroupsFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: null,
|
||||
limit: null,
|
||||
runtime,
|
||||
});
|
||||
expect(groups?.map((e) => e.id).sort()).toEqual(["channel:555", "channel:666"]);
|
||||
});
|
||||
|
||||
it("lists Telegram peers/groups from config", async () => {
|
||||
const { telegramPlugin } = await import("./telegram.js");
|
||||
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as any;
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
@@ -96,28 +96,24 @@ describe("directory (config-backed)", () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const peers = await telegramPlugin.directory?.listPeers?.({
|
||||
const peers = await listTelegramDirectoryPeersFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: null,
|
||||
limit: null,
|
||||
runtime,
|
||||
});
|
||||
expect(peers?.map((e) => e.id).sort()).toEqual(["123", "456", "@alice", "@bob"]);
|
||||
|
||||
const groups = await telegramPlugin.directory?.listGroups?.({
|
||||
const groups = await listTelegramDirectoryGroupsFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: null,
|
||||
limit: null,
|
||||
runtime,
|
||||
});
|
||||
expect(groups?.map((e) => e.id)).toEqual(["-1001"]);
|
||||
});
|
||||
|
||||
it("lists WhatsApp peers/groups from config", async () => {
|
||||
const { whatsappPlugin } = await import("./whatsapp.js");
|
||||
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as any;
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -127,21 +123,19 @@ describe("directory (config-backed)", () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const peers = await whatsappPlugin.directory?.listPeers?.({
|
||||
const peers = await listWhatsAppDirectoryPeersFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: null,
|
||||
limit: null,
|
||||
runtime,
|
||||
});
|
||||
expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]);
|
||||
|
||||
const groups = await whatsappPlugin.directory?.listGroups?.({
|
||||
const groups = await listWhatsAppDirectoryGroupsFromConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
query: null,
|
||||
limit: null,
|
||||
runtime,
|
||||
});
|
||||
expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { ClawdbotConfig } from "../../config/types.js";
|
||||
import type { ChannelDirectoryEntry } from "./types.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { resolveDiscordAccount } from "../../discord/accounts.js";
|
||||
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
||||
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||
import { normalizeSlackMessagingTarget } from "./normalize-target.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
|
||||
export type DirectoryConfigParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
};
|
||||
|
||||
export async function listSlackDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of account.dm?.allowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") continue;
|
||||
ids.add(raw);
|
||||
}
|
||||
for (const id of Object.keys(account.config.dms ?? {})) {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed) ids.add(trimmed);
|
||||
}
|
||||
for (const channel of Object.values(account.config.channels ?? {})) {
|
||||
for (const user of channel.users ?? []) {
|
||||
const raw = String(user).trim();
|
||||
if (raw) ids.add(raw);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
|
||||
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
|
||||
if (!normalizedUserId) return null;
|
||||
const target = `user:${normalizedUserId}`;
|
||||
return normalizeSlackMessagingTarget(target) ?? target.toLowerCase();
|
||||
})
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => id.startsWith("user:"))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
}
|
||||
|
||||
export async function listSlackDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
return Object.keys(account.config.channels ?? {})
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => normalizeSlackMessagingTarget(raw) ?? raw.toLowerCase())
|
||||
.filter((id) => id.startsWith("channel:"))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
}
|
||||
|
||||
export async function listDiscordDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of account.config.dm?.allowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") continue;
|
||||
ids.add(raw);
|
||||
}
|
||||
for (const id of Object.keys(account.config.dms ?? {})) {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed) ids.add(trimmed);
|
||||
}
|
||||
for (const guild of Object.values(account.config.guilds ?? {})) {
|
||||
for (const entry of guild.users ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (raw) ids.add(raw);
|
||||
}
|
||||
for (const channel of Object.values(guild.channels ?? {})) {
|
||||
for (const user of channel.users ?? []) {
|
||||
const raw = String(user).trim();
|
||||
if (raw) ids.add(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const mention = raw.match(/^<@!?(\d+)>$/);
|
||||
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
|
||||
if (!/^\d+$/.test(cleaned)) return null;
|
||||
return `user:${cleaned}`;
|
||||
})
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
}
|
||||
|
||||
export async function listDiscordDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
for (const guild of Object.values(account.config.guilds ?? {})) {
|
||||
for (const channelId of Object.keys(guild.channels ?? {})) {
|
||||
const trimmed = channelId.trim();
|
||||
if (trimmed) ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const mention = raw.match(/^<#(\d+)>$/);
|
||||
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
|
||||
if (!/^\d+$/.test(cleaned)) return null;
|
||||
return `channel:${cleaned}`;
|
||||
})
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
}
|
||||
|
||||
export async function listTelegramDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const raw = [
|
||||
...(account.config.allowFrom ?? []).map((entry) => String(entry)),
|
||||
...Object.keys(account.config.dms ?? {}),
|
||||
];
|
||||
return Array.from(
|
||||
new Set(
|
||||
raw
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(telegram|tg):/i, "")),
|
||||
),
|
||||
)
|
||||
.map((entry) => {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^-?\d+$/.test(trimmed)) return trimmed;
|
||||
const withAt = trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
|
||||
return withAt;
|
||||
})
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
}
|
||||
|
||||
export async function listTelegramDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
return Object.keys(account.config.groups ?? {})
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => Boolean(id) && id !== "*")
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
}
|
||||
|
||||
export async function listWhatsAppDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
return (account.allowFrom ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry) => Boolean(entry) && entry !== "*")
|
||||
.map((entry) => normalizeWhatsAppTarget(entry) ?? "")
|
||||
.filter(Boolean)
|
||||
.filter((id) => !isWhatsAppGroupJid(id))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
}
|
||||
|
||||
export async function listWhatsAppDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
return Object.keys(account.groups ?? {})
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => Boolean(id) && id !== "*")
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
}
|
||||
@@ -26,10 +26,7 @@ import {
|
||||
} from "./config-helpers.js";
|
||||
import { resolveDiscordGroupRequireMention } from "./group-mentions.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import {
|
||||
looksLikeDiscordTargetId,
|
||||
normalizeDiscordMessagingTarget,
|
||||
} from "./normalize-target.js";
|
||||
import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget } from "./normalize-target.js";
|
||||
import { discordOnboardingAdapter } from "./onboarding/discord.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
||||
import {
|
||||
@@ -38,6 +35,10 @@ import {
|
||||
} from "./setup-helpers.js";
|
||||
import { collectDiscordStatusIssues } from "./status-issues/discord.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
import {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
|
||||
const meta = getChatChannelMeta("discord");
|
||||
|
||||
@@ -152,79 +153,15 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeDiscordMessagingTarget,
|
||||
looksLikeTargetId: looksLikeDiscordTargetId,
|
||||
targetHint: "<channelId|user:ID|channel:ID>",
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeDiscordTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of account.config.dm?.allowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") continue;
|
||||
ids.add(raw);
|
||||
}
|
||||
for (const id of Object.keys(account.config.dms ?? {})) {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed) ids.add(trimmed);
|
||||
}
|
||||
for (const guild of Object.values(account.config.guilds ?? {})) {
|
||||
for (const entry of guild.users ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (raw) ids.add(raw);
|
||||
}
|
||||
for (const channel of Object.values(guild.channels ?? {})) {
|
||||
for (const user of channel.users ?? []) {
|
||||
const raw = String(user).trim();
|
||||
if (raw) ids.add(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const peers = Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const mention = raw.match(/^<@!?(\d+)>$/);
|
||||
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
|
||||
if (!/^\d+$/.test(cleaned)) return null;
|
||||
return `user:${cleaned}`;
|
||||
})
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
return peers;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
for (const guild of Object.values(account.config.guilds ?? {})) {
|
||||
for (const channelId of Object.keys(guild.channels ?? {})) {
|
||||
const trimmed = channelId.trim();
|
||||
if (trimmed) ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const mention = raw.match(/^<#(\d+)>$/);
|
||||
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
|
||||
if (!/^\d+$/.test(cleaned)) return null;
|
||||
return `channel:${cleaned}`;
|
||||
})
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return groups;
|
||||
},
|
||||
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
|
||||
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
|
||||
listGroupsLive: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
type GroupMentionParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
groupId?: string | null;
|
||||
groupRoom?: string | null;
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
};
|
||||
@@ -133,13 +133,14 @@ export function resolveDiscordGroupRequireMention(params: GroupMentionParams): b
|
||||
);
|
||||
const channelEntries = guildEntry?.channels;
|
||||
if (channelEntries && Object.keys(channelEntries).length > 0) {
|
||||
const channelSlug = normalizeDiscordSlug(params.groupRoom);
|
||||
const groupChannel = params.groupChannel;
|
||||
const channelSlug = normalizeDiscordSlug(groupChannel);
|
||||
const entry =
|
||||
(params.groupId ? channelEntries[params.groupId] : undefined) ??
|
||||
(channelSlug
|
||||
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
||||
: undefined) ??
|
||||
(params.groupRoom ? channelEntries[normalizeDiscordSlug(params.groupRoom)] : undefined);
|
||||
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
|
||||
if (entry && typeof entry.requireMention === "boolean") {
|
||||
return entry.requireMention;
|
||||
}
|
||||
@@ -159,7 +160,8 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo
|
||||
const keys = Object.keys(channels);
|
||||
if (keys.length === 0) return true;
|
||||
const channelId = params.groupId?.trim();
|
||||
const channelName = params.groupRoom?.replace(/^#/, "");
|
||||
const groupChannel = params.groupChannel;
|
||||
const channelName = groupChannel?.replace(/^#/, "");
|
||||
const normalizedName = normalizeSlackSlug(channelName);
|
||||
const candidates = [
|
||||
channelId ?? "",
|
||||
|
||||
@@ -107,14 +107,16 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
},
|
||||
messaging: {
|
||||
looksLikeTargetId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(imessage:|chat_id:)/i.test(trimmed)) return true;
|
||||
if (trimmed.includes("@")) return true;
|
||||
return /^\+?\d{3,}$/.test(trimmed);
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(imessage:|chat_id:)/i.test(trimmed)) return true;
|
||||
if (trimmed.includes("@")) return true;
|
||||
return /^\+?\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<handle|chat_id:ID>",
|
||||
},
|
||||
targetHint: "<handle|chat_id:ID>",
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
|
||||
@@ -74,4 +74,14 @@ export function normalizeChannelId(raw?: string | null): ChannelId | null {
|
||||
}
|
||||
|
||||
export { discordPlugin, imessagePlugin, signalPlugin, slackPlugin, telegramPlugin, whatsappPlugin };
|
||||
export {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
listSlackDirectoryGroupsFromConfig,
|
||||
listSlackDirectoryPeersFromConfig,
|
||||
listTelegramDirectoryGroupsFromConfig,
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
listWhatsAppDirectoryGroupsFromConfig,
|
||||
listWhatsAppDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
export type { ChannelId, ChannelPlugin } from "./types.js";
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
} from "./config-helpers.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import { resolveChannelMediaMaxBytes } from "./media-limits.js";
|
||||
import {
|
||||
looksLikeSignalTargetId,
|
||||
normalizeSignalMessagingTarget,
|
||||
} from "./normalize-target.js";
|
||||
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize-target.js";
|
||||
import { signalOnboardingAdapter } from "./onboarding/signal.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
||||
import {
|
||||
@@ -118,8 +115,10 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeSignalMessagingTarget,
|
||||
looksLikeTargetId: looksLikeSignalTargetId,
|
||||
targetHint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeSignalTargetId,
|
||||
hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "./setup-helpers.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
|
||||
import {
|
||||
listSlackDirectoryGroupsFromConfig,
|
||||
listSlackDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
|
||||
const meta = getChatChannelMeta("slack");
|
||||
|
||||
@@ -177,62 +181,15 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeSlackMessagingTarget,
|
||||
looksLikeTargetId: looksLikeSlackTargetId,
|
||||
targetHint: "<channelId|user:ID|channel:ID>",
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeSlackTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of account.dm?.allowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") continue;
|
||||
ids.add(raw);
|
||||
}
|
||||
for (const id of Object.keys(account.config.dms ?? {})) {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed) ids.add(trimmed);
|
||||
}
|
||||
for (const channel of Object.values(account.config.channels ?? {})) {
|
||||
for (const user of channel.users ?? []) {
|
||||
const raw = String(user).trim();
|
||||
if (raw) ids.add(raw);
|
||||
}
|
||||
}
|
||||
|
||||
const peers = Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
|
||||
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
|
||||
if (!normalizedUserId) return null;
|
||||
const target = `user:${normalizedUserId}`;
|
||||
return normalizeSlackMessagingTarget(target) ?? target.toLowerCase();
|
||||
})
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => id.startsWith("user:"))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
return peers;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const groups = Object.keys(account.config.channels ?? {})
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => normalizeSlackMessagingTarget(raw) ?? raw.toLowerCase())
|
||||
.filter((id) => id.startsWith("channel:"))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return groups;
|
||||
},
|
||||
listPeers: async (params) => listSlackDirectoryPeersFromConfig(params),
|
||||
listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params),
|
||||
},
|
||||
actions: {
|
||||
listActions: ({ cfg }) => {
|
||||
|
||||
@@ -26,10 +26,7 @@ import {
|
||||
} from "./config-helpers.js";
|
||||
import { resolveTelegramGroupRequireMention } from "./group-mentions.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import {
|
||||
looksLikeTelegramTargetId,
|
||||
normalizeTelegramMessagingTarget,
|
||||
} from "./normalize-target.js";
|
||||
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize-target.js";
|
||||
import { telegramOnboardingAdapter } from "./onboarding/telegram.js";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "./pairing-message.js";
|
||||
import {
|
||||
@@ -38,6 +35,10 @@ import {
|
||||
} from "./setup-helpers.js";
|
||||
import { collectTelegramStatusIssues } from "./status-issues/telegram.js";
|
||||
import type { ChannelPlugin } from "./types.js";
|
||||
import {
|
||||
listTelegramDirectoryGroupsFromConfig,
|
||||
listTelegramDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
|
||||
const meta = getChatChannelMeta("telegram");
|
||||
|
||||
@@ -160,50 +161,15 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeTelegramMessagingTarget,
|
||||
looksLikeTargetId: looksLikeTelegramTargetId,
|
||||
targetHint: "<chatId>",
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeTelegramTargetId,
|
||||
hint: "<chatId>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveTelegramAccount({ cfg, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const raw = [
|
||||
...(account.config.allowFrom ?? []).map((entry) => String(entry)),
|
||||
...Object.keys(account.config.dms ?? {}),
|
||||
];
|
||||
const peers = Array.from(
|
||||
new Set(
|
||||
raw
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(telegram|tg):/i, "")),
|
||||
),
|
||||
)
|
||||
.map((entry) => {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^-?\d+$/.test(trimmed)) return trimmed;
|
||||
const withAt = trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
|
||||
return withAt;
|
||||
})
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
return peers;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveTelegramAccount({ cfg, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const groups = Object.keys(account.config.groups ?? {})
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => Boolean(id) && id !== "*")
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return groups;
|
||||
},
|
||||
listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params),
|
||||
listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params),
|
||||
},
|
||||
actions: telegramMessageActions,
|
||||
setup: {
|
||||
|
||||
@@ -133,7 +133,8 @@ export type ChannelLogSink = {
|
||||
export type ChannelGroupContext = {
|
||||
cfg: ClawdbotConfig;
|
||||
groupId?: string | null;
|
||||
groupRoom?: string | null;
|
||||
/** Human label for channel-like group conversations (e.g. #general). */
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
};
|
||||
@@ -216,13 +217,15 @@ export type ChannelThreadingToolContext = {
|
||||
|
||||
export type ChannelMessagingAdapter = {
|
||||
normalizeTarget?: (raw: string) => string | undefined;
|
||||
looksLikeTargetId?: (raw: string, normalized?: string) => boolean;
|
||||
targetResolver?: {
|
||||
looksLikeId?: (raw: string, normalized?: string) => boolean;
|
||||
hint?: string;
|
||||
};
|
||||
formatTargetDisplay?: (params: {
|
||||
target: string;
|
||||
display?: string;
|
||||
kind?: ChannelDirectoryEntryKind;
|
||||
}) => string;
|
||||
targetHint?: string;
|
||||
};
|
||||
|
||||
export type ChannelDirectoryEntryKind = "user" | "group" | "channel";
|
||||
|
||||
@@ -26,10 +26,7 @@ import { buildChannelConfigSchema } from "./config-schema.js";
|
||||
import { createWhatsAppLoginTool } from "./agent-tools/whatsapp-login.js";
|
||||
import { resolveWhatsAppGroupRequireMention } from "./group-mentions.js";
|
||||
import { formatPairingApproveHint } from "./helpers.js";
|
||||
import {
|
||||
looksLikeWhatsAppTargetId,
|
||||
normalizeWhatsAppMessagingTarget,
|
||||
} from "./normalize-target.js";
|
||||
import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize-target.js";
|
||||
import { whatsappOnboardingAdapter } from "./onboarding/whatsapp.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
@@ -39,6 +36,10 @@ import { collectWhatsAppStatusIssues } from "./status-issues/whatsapp.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
|
||||
import { resolveWhatsAppHeartbeatRecipients } from "./whatsapp-heartbeat.js";
|
||||
import { missingTargetError } from "../../infra/outbound/target-errors.js";
|
||||
import {
|
||||
listWhatsAppDirectoryGroupsFromConfig,
|
||||
listWhatsAppDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
|
||||
const meta = getChatChannelMeta("whatsapp");
|
||||
|
||||
@@ -222,8 +223,10 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeWhatsAppMessagingTarget,
|
||||
looksLikeTargetId: looksLikeWhatsAppTargetId,
|
||||
targetHint: "<E.164|group JID>",
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeWhatsAppTargetId,
|
||||
hint: "<E.164|group JID>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async ({ cfg, accountId }) => {
|
||||
@@ -238,31 +241,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
raw: { e164, jid },
|
||||
};
|
||||
},
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveWhatsAppAccount({ cfg, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const peers = (account.allowFrom ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry) => Boolean(entry) && entry !== "*")
|
||||
.map((entry) => normalizeWhatsAppTarget(entry) ?? "")
|
||||
.filter(Boolean)
|
||||
.filter((id) => !isWhatsAppGroupJid(id))
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
return peers;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveWhatsAppAccount({ cfg, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const groups = Object.keys(account.groups ?? {})
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => Boolean(id) && id !== "*")
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return groups;
|
||||
},
|
||||
listPeers: async (params) => listWhatsAppDirectoryPeersFromConfig(params),
|
||||
listGroups: async (params) => listWhatsAppDirectoryGroupsFromConfig(params),
|
||||
},
|
||||
actions: {
|
||||
listActions: ({ cfg }) => {
|
||||
|
||||
@@ -129,7 +129,9 @@ export function normalizeAnyChannelId(raw?: string | null): ChannelId | null {
|
||||
if (!registry) return null;
|
||||
|
||||
const hit = registry.channels.find((entry) => {
|
||||
const id = String(entry.plugin.id ?? "").trim().toLowerCase();
|
||||
const id = String(entry.plugin.id ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (id && id === key) return true;
|
||||
return (entry.plugin.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === key);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HookStatusReport } from "../hooks/hooks-status.js";
|
||||
import { formatHooksCheck, formatHooksList } from "./hooks-cli.js";
|
||||
|
||||
const report: HookStatusReport = {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
managedHooksDir: "/tmp/hooks",
|
||||
hooks: [
|
||||
{
|
||||
name: "session-memory",
|
||||
description: "Save session context to memory",
|
||||
source: "clawdbot-bundled",
|
||||
filePath: "/tmp/hooks/session-memory/HOOK.md",
|
||||
baseDir: "/tmp/hooks/session-memory",
|
||||
handlerPath: "/tmp/hooks/session-memory/handler.js",
|
||||
hookKey: "session-memory",
|
||||
emoji: "💾",
|
||||
homepage: "https://docs.clawd.bot/hooks#session-memory",
|
||||
events: ["command:new"],
|
||||
always: false,
|
||||
disabled: false,
|
||||
eligible: true,
|
||||
requirements: {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
missing: {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
},
|
||||
configChecks: [],
|
||||
install: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("hooks cli formatting", () => {
|
||||
it("labels hooks list output", () => {
|
||||
const output = formatHooksList(report, {});
|
||||
expect(output).toContain("Hooks");
|
||||
expect(output).not.toContain("Internal Hooks");
|
||||
});
|
||||
|
||||
it("labels hooks status output", () => {
|
||||
const output = formatHooksCheck(report, {});
|
||||
expect(output).toContain("Hooks Status");
|
||||
});
|
||||
});
|
||||
+12
-11
@@ -12,7 +12,11 @@ import {
|
||||
type HookStatusReport,
|
||||
} from "../hooks/hooks-status.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/io.js";
|
||||
import { installHooksFromNpmSpec, installHooksFromPath, resolveHookInstallDir } from "../hooks/install.js";
|
||||
import {
|
||||
installHooksFromNpmSpec,
|
||||
installHooksFromPath,
|
||||
resolveHookInstallDir,
|
||||
} from "../hooks/install.js";
|
||||
import { recordHookInstall } from "../hooks/installs.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
@@ -125,9 +129,7 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
|
||||
const notEligible = hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
chalk.bold.cyan("Internal Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`),
|
||||
);
|
||||
lines.push(chalk.bold.cyan("Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`));
|
||||
lines.push("");
|
||||
|
||||
if (eligible.length > 0) {
|
||||
@@ -273,7 +275,7 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
|
||||
const notEligible = report.hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.bold.cyan("Internal Hooks Status"));
|
||||
lines.push(chalk.bold.cyan("Hooks Status"));
|
||||
lines.push("");
|
||||
lines.push(`Total hooks: ${report.hooks.length}`);
|
||||
lines.push(chalk.green(`Ready: ${eligible.length}`));
|
||||
@@ -373,7 +375,7 @@ export function registerHooksCli(program: Command): void {
|
||||
|
||||
hooks
|
||||
.command("list")
|
||||
.description("List all internal hooks")
|
||||
.description("List all hooks")
|
||||
.option("--eligible", "Show only eligible hooks", false)
|
||||
.option("--json", "Output as JSON", false)
|
||||
.option("-v, --verbose", "Show more details including missing requirements", false)
|
||||
@@ -679,15 +681,15 @@ export function registerHooksCli(program: Command): void {
|
||||
for (const hookId of targets) {
|
||||
const record = installs[hookId];
|
||||
if (!record) {
|
||||
defaultRuntime.log(chalk.yellow(`No install record for \"${hookId}\".`));
|
||||
defaultRuntime.log(chalk.yellow(`No install record for "${hookId}".`));
|
||||
continue;
|
||||
}
|
||||
if (record.source !== "npm") {
|
||||
defaultRuntime.log(chalk.yellow(`Skipping \"${hookId}\" (source: ${record.source}).`));
|
||||
defaultRuntime.log(chalk.yellow(`Skipping "${hookId}" (source: ${record.source}).`));
|
||||
continue;
|
||||
}
|
||||
if (!record.spec) {
|
||||
defaultRuntime.log(chalk.yellow(`Skipping \"${hookId}\" (missing npm spec).`));
|
||||
defaultRuntime.log(chalk.yellow(`Skipping "${hookId}" (missing npm spec).`));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -734,8 +736,7 @@ export function registerHooksCli(program: Command): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextVersion =
|
||||
result.version ?? (await readInstalledPackageVersion(result.targetDir));
|
||||
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
|
||||
nextCfg = recordHookInstall(nextCfg, {
|
||||
hookId,
|
||||
source: "npm",
|
||||
|
||||
@@ -51,7 +51,9 @@ export async function deliverAgentCommandResult(params: {
|
||||
isInternalMessageChannel(deliveryChannel) || Boolean(deliveryPlugin);
|
||||
|
||||
const targetMode =
|
||||
opts.deliveryTargetMode ?? deliveryPlan.deliveryTargetMode ?? (opts.to ? "explicit" : "implicit");
|
||||
opts.deliveryTargetMode ??
|
||||
deliveryPlan.deliveryTargetMode ??
|
||||
(opts.to ? "explicit" : "implicit");
|
||||
const resolvedAccountId = deliveryPlan.resolvedAccountId;
|
||||
const resolved =
|
||||
deliver && isDeliveryChannelKnown && deliveryChannel
|
||||
|
||||
@@ -243,10 +243,7 @@ export function formatMessageCliText(result: MessageActionRunResult): string[] {
|
||||
const results = result.payload.results ?? [];
|
||||
const rows = results.map((entry) => ({
|
||||
Channel: resolveChannelLabel(entry.channel),
|
||||
Target: shortenText(
|
||||
formatTargetDisplay({ channel: entry.channel, target: entry.to }),
|
||||
36,
|
||||
),
|
||||
Target: shortenText(formatTargetDisplay({ channel: entry.channel, target: entry.to }), 36),
|
||||
Status: entry.ok ? "ok" : "error",
|
||||
Error: entry.ok ? "" : shortenText(entry.error ?? "unknown error", 48),
|
||||
}));
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("onboard-hooks", () => {
|
||||
});
|
||||
|
||||
describe("setupInternalHooks", () => {
|
||||
it("should enable internal hooks when user selects them", async () => {
|
||||
it("should enable hooks when user selects them", async () => {
|
||||
const { buildWorkspaceHookStatus } = await import("../hooks/hooks-status.js");
|
||||
vi.mocked(buildWorkspaceHookStatus).mockReturnValue(createMockHookReport());
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("onboard-hooks", () => {
|
||||
});
|
||||
expect(prompter.note).toHaveBeenCalledTimes(2);
|
||||
expect(prompter.multiselect).toHaveBeenCalledWith({
|
||||
message: "Enable internal hooks?",
|
||||
message: "Enable hooks?",
|
||||
options: [
|
||||
{ value: "__skip__", label: "Skip for now" },
|
||||
{
|
||||
@@ -173,8 +173,8 @@ describe("onboard-hooks", () => {
|
||||
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
|
||||
expect(noteCalls).toHaveLength(2);
|
||||
|
||||
// First note should explain what internal hooks are
|
||||
expect(noteCalls[0][0]).toContain("Internal hooks");
|
||||
// First note should explain what hooks are
|
||||
expect(noteCalls[0][0]).toContain("Hooks let you automate actions");
|
||||
expect(noteCalls[0][0]).toContain("automate actions");
|
||||
|
||||
// Second note should confirm configuration
|
||||
|
||||
@@ -11,12 +11,12 @@ export async function setupInternalHooks(
|
||||
): Promise<ClawdbotConfig> {
|
||||
await prompter.note(
|
||||
[
|
||||
"Internal hooks let you automate actions when agent commands are issued.",
|
||||
"Hooks let you automate actions when agent commands are issued.",
|
||||
"Example: Save session context to memory when you issue /new.",
|
||||
"",
|
||||
"Learn more: https://docs.clawd.bot/internal-hooks",
|
||||
"Learn more: https://docs.clawd.bot/hooks",
|
||||
].join("\n"),
|
||||
"Internal Hooks",
|
||||
"Hooks",
|
||||
);
|
||||
|
||||
// Discover available hooks using the hook discovery system
|
||||
@@ -35,7 +35,7 @@ export async function setupInternalHooks(
|
||||
}
|
||||
|
||||
const toEnable = await prompter.multiselect({
|
||||
message: "Enable internal hooks?",
|
||||
message: "Enable hooks?",
|
||||
options: [
|
||||
{ value: "__skip__", label: "Skip for now" },
|
||||
...recommendedHooks.map((hook) => ({
|
||||
|
||||
@@ -16,8 +16,8 @@ describe("resolveChannelCapabilities", () => {
|
||||
|
||||
it("returns undefined for missing inputs", () => {
|
||||
expect(resolveChannelCapabilities({})).toBeUndefined();
|
||||
expect(resolveChannelCapabilities({ cfg: {} as ClawdbotConfig })).toBeUndefined();
|
||||
expect(resolveChannelCapabilities({ cfg: {} as ClawdbotConfig, channel: "" })).toBeUndefined();
|
||||
expect(resolveChannelCapabilities({ cfg: {} })).toBeUndefined();
|
||||
expect(resolveChannelCapabilities({ cfg: {}, channel: "" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes and prefers per-account capabilities", () => {
|
||||
@@ -36,7 +36,7 @@ describe("resolveChannelCapabilities", () => {
|
||||
|
||||
expect(
|
||||
resolveChannelCapabilities({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
}),
|
||||
@@ -57,7 +57,7 @@ describe("resolveChannelCapabilities", () => {
|
||||
|
||||
expect(
|
||||
resolveChannelCapabilities({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
}),
|
||||
@@ -77,7 +77,7 @@ describe("resolveChannelCapabilities", () => {
|
||||
|
||||
expect(
|
||||
resolveChannelCapabilities({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: "family",
|
||||
}),
|
||||
@@ -100,11 +100,31 @@ describe("resolveChannelCapabilities", () => {
|
||||
|
||||
expect(
|
||||
resolveChannelCapabilities({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
cfg,
|
||||
channel: "msteams",
|
||||
}),
|
||||
).toEqual(["polls"]);
|
||||
});
|
||||
|
||||
it("handles object-format capabilities gracefully (e.g., { inlineButtons: 'dm' })", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
// Object format - used for granular control like inlineButtons scope.
|
||||
// Channel-specific handlers (resolveTelegramInlineButtonsScope) process these.
|
||||
capabilities: { inlineButtons: "dm" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Should return undefined (not crash), allowing channel-specific handlers to process it.
|
||||
expect(
|
||||
resolveChannelCapabilities({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import type { ClawdbotConfig } from "./config.js";
|
||||
import type { TelegramCapabilitiesConfig } from "./types.telegram.js";
|
||||
|
||||
function normalizeCapabilities(capabilities: string[] | undefined): string[] | undefined {
|
||||
if (!capabilities) return undefined;
|
||||
type CapabilitiesConfig = TelegramCapabilitiesConfig;
|
||||
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
||||
|
||||
function normalizeCapabilities(capabilities: CapabilitiesConfig | undefined): string[] | undefined {
|
||||
// Handle object-format capabilities (e.g., { inlineButtons: "dm" }) gracefully.
|
||||
// Channel-specific handlers (like resolveTelegramInlineButtonsScope) process these separately.
|
||||
if (!isStringArray(capabilities)) return undefined;
|
||||
const normalized = capabilities.map((entry) => entry.trim()).filter(Boolean);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function resolveAccountCapabilities(params: {
|
||||
cfg?: { accounts?: Record<string, { capabilities?: string[] }> } & {
|
||||
capabilities?: string[];
|
||||
cfg?: { accounts?: Record<string, { capabilities?: CapabilitiesConfig }> } & {
|
||||
capabilities?: CapabilitiesConfig;
|
||||
};
|
||||
accountId?: string | null;
|
||||
}): string[] | undefined {
|
||||
@@ -37,7 +45,7 @@ function resolveAccountCapabilities(params: {
|
||||
}
|
||||
|
||||
export function resolveChannelCapabilities(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
cfg?: Partial<ClawdbotConfig>;
|
||||
channel?: string | null;
|
||||
accountId?: string | null;
|
||||
}): string[] | undefined {
|
||||
@@ -48,8 +56,8 @@ export function resolveChannelCapabilities(params: {
|
||||
const channelsConfig = cfg.channels as Record<string, unknown> | undefined;
|
||||
const channelConfig = (channelsConfig?.[channel] ?? (cfg as Record<string, unknown>)[channel]) as
|
||||
| {
|
||||
accounts?: Record<string, { capabilities?: string[] }>;
|
||||
capabilities?: string[];
|
||||
accounts?: Record<string, { capabilities?: CapabilitiesConfig }>;
|
||||
capabilities?: CapabilitiesConfig;
|
||||
}
|
||||
| undefined;
|
||||
return resolveAccountCapabilities({
|
||||
|
||||
@@ -90,7 +90,7 @@ export type HookInstallRecord = {
|
||||
};
|
||||
|
||||
export type InternalHooksConfig = {
|
||||
/** Enable internal hooks system */
|
||||
/** Enable hooks system */
|
||||
enabled?: boolean;
|
||||
/** Legacy: List of internal hook handlers to register (still supported) */
|
||||
handlers?: InternalHookHandlerConfig[];
|
||||
|
||||
@@ -251,11 +251,7 @@ export const MediaUnderstandingScopeSchema = z
|
||||
.object({
|
||||
channel: z.string().optional(),
|
||||
chatType: z
|
||||
.union([
|
||||
z.literal("direct"),
|
||||
z.literal("group"),
|
||||
z.literal("channel"),
|
||||
])
|
||||
.union([z.literal("direct"), z.literal("group"), z.literal("channel")])
|
||||
.optional(),
|
||||
keyPrefix: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -39,11 +39,7 @@ export const SessionSchema = z
|
||||
.object({
|
||||
channel: z.string().optional(),
|
||||
chatType: z
|
||||
.union([
|
||||
z.literal("direct"),
|
||||
z.literal("group"),
|
||||
z.literal("channel"),
|
||||
])
|
||||
.union([z.literal("direct"), z.literal("group"), z.literal("channel")])
|
||||
.optional(),
|
||||
keyPrefix: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -319,38 +319,38 @@ export async function preflightDiscordMessage(
|
||||
surface: "discord",
|
||||
});
|
||||
|
||||
if (!isDirectMessage) {
|
||||
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
|
||||
const ownerOk = ownerAllowList
|
||||
? allowListMatches(ownerAllowList, {
|
||||
if (!isDirectMessage) {
|
||||
const ownerAllowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:"]);
|
||||
const ownerOk = ownerAllowList
|
||||
? allowListMatches(ownerAllowList, {
|
||||
id: author.id,
|
||||
name: author.username,
|
||||
tag: formatDiscordUserTag(author),
|
||||
})
|
||||
: false;
|
||||
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
||||
const usersOk =
|
||||
Array.isArray(channelUsers) && channelUsers.length > 0
|
||||
? resolveDiscordUserAllowed({
|
||||
allowList: channelUsers,
|
||||
userId: author.id,
|
||||
userName: author.username,
|
||||
userTag: formatDiscordUserTag(author),
|
||||
})
|
||||
: false;
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: ownerAllowList != null, allowed: ownerOk },
|
||||
{ configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk },
|
||||
],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
const channelUsers = channelConfig?.users ?? guildInfo?.users;
|
||||
const usersOk =
|
||||
Array.isArray(channelUsers) && channelUsers.length > 0
|
||||
? resolveDiscordUserAllowed({
|
||||
allowList: channelUsers,
|
||||
userId: author.id,
|
||||
userName: author.username,
|
||||
userTag: formatDiscordUserTag(author),
|
||||
})
|
||||
: false;
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: ownerAllowList != null, allowed: ownerOk },
|
||||
{ configured: Array.isArray(channelUsers) && channelUsers.length > 0, allowed: usersOk },
|
||||
],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
|
||||
if (allowTextCommands && hasControlCommand(baseText, params.cfg) && !commandAuthorized) {
|
||||
logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`);
|
||||
return null;
|
||||
if (allowTextCommands && hasControlCommand(baseText, params.cfg) && !commandAuthorized) {
|
||||
logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,8 +124,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
senderDisplay && senderTag && senderDisplay !== senderTag
|
||||
? `${senderDisplay} (${senderTag})`
|
||||
: (senderDisplay ?? senderTag ?? author.id);
|
||||
const groupRoom = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
||||
const groupSubject = isDirectMessage ? undefined : groupRoom;
|
||||
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
||||
const groupSubject = isDirectMessage ? undefined : groupChannel;
|
||||
const channelDescription = channelInfo?.topic?.trim();
|
||||
const systemPromptParts = [
|
||||
channelDescription ? `Channel topic: ${channelDescription}` : null,
|
||||
@@ -245,7 +245,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
SenderUsername: author.username,
|
||||
SenderTag: formatDiscordUserTag(author),
|
||||
GroupSubject: groupSubject,
|
||||
GroupRoom: groupRoom,
|
||||
GroupChannel: groupChannel,
|
||||
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
|
||||
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
|
||||
Provider: "discord" as const,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { toAgentRequestSessionKey } from "../routing/session-key.js";
|
||||
|
||||
export function resolveSessionKeyForRun(runId: string) {
|
||||
const cached = getAgentRunContext(runId)?.sessionKey;
|
||||
@@ -12,8 +12,7 @@ export function resolveSessionKeyForRun(runId: string) {
|
||||
const found = Object.entries(store).find(([, entry]) => entry?.sessionId === runId);
|
||||
const storeKey = found?.[0];
|
||||
if (storeKey) {
|
||||
const parsed = parseAgentSessionKey(storeKey);
|
||||
const sessionKey = parsed?.rest ?? storeKey;
|
||||
const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey;
|
||||
registerAgentRunContext(runId, { sessionKey });
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function startGatewaySidecars(params: {
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
params.logHooks.error(`failed to load internal hooks: ${String(err)}`);
|
||||
params.logHooks.error(`failed to load hooks: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Launch configured channels so gateway replies via the surface the message came from.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
@@ -26,22 +27,16 @@ describe("gateway server agent", () => {
|
||||
testState.allowFrom = ["+436769770569"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-stale",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-stale",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -70,20 +65,14 @@ describe("gateway server agent", () => {
|
||||
test("agent forwards sessionKey to agentCommand", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:subagent:abc": {
|
||||
sessionId: "sess-sub",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"agent:main:subagent:abc": {
|
||||
sessionId: "sess-sub",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -111,23 +100,17 @@ describe("gateway server agent", () => {
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-account",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "default",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-account",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "default",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -156,23 +139,17 @@ describe("gateway server agent", () => {
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-explicit",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "legacy",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-explicit",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "legacy",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -201,23 +178,17 @@ describe("gateway server agent", () => {
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-explicit-account",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "legacy",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-explicit-account",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "legacy",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -247,23 +218,17 @@ describe("gateway server agent", () => {
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-implicit",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "kev",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-implicit",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "kev",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -290,20 +255,14 @@ describe("gateway server agent", () => {
|
||||
test("agent forwards image attachments as images[]", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-images",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-images",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -343,20 +302,14 @@ describe("gateway server agent", () => {
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-missing-provider",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-missing-provider",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -384,22 +337,16 @@ describe("gateway server agent", () => {
|
||||
test("agent routes main last-channel whatsapp", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-whatsapp",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-whatsapp",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -429,22 +376,16 @@ describe("gateway server agent", () => {
|
||||
test("agent routes main last-channel telegram", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "telegram",
|
||||
lastTo: "123",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -473,22 +414,16 @@ describe("gateway server agent", () => {
|
||||
test("agent routes main last-channel discord", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-discord",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "discord",
|
||||
lastTo: "channel:discord-123",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-discord",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "discord",
|
||||
lastTo: "channel:discord-123",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -517,22 +452,16 @@ describe("gateway server agent", () => {
|
||||
test("agent routes main last-channel slack", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-slack",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "slack",
|
||||
lastTo: "channel:slack-123",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-slack",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "slack",
|
||||
lastTo: "channel:slack-123",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -561,22 +490,16 @@ describe("gateway server agent", () => {
|
||||
test("agent routes main last-channel signal", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-signal",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "signal",
|
||||
lastTo: "+15551234567",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-signal",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "signal",
|
||||
lastTo: "+15551234567",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
@@ -80,22 +81,16 @@ describe("gateway server agent", () => {
|
||||
setActivePluginRegistry(registry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-teams",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "msteams",
|
||||
lastTo: "conversation:teams-123",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-teams",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "msteams",
|
||||
lastTo: "conversation:teams-123",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -133,22 +128,16 @@ describe("gateway server agent", () => {
|
||||
setActivePluginRegistry(registry);
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-alias",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "imessage",
|
||||
lastTo: "chat_id:123",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-alias",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "imessage",
|
||||
lastTo: "chat_id:123",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -206,22 +195,16 @@ describe("gateway server agent", () => {
|
||||
testState.allowFrom = ["+1555"];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-webchat",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "webchat",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-webchat",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "webchat",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -250,22 +233,16 @@ describe("gateway server agent", () => {
|
||||
test("agent uses webchat for internal runs when last provider is webchat", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main-webchat-internal",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "webchat",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main-webchat-internal",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "webchat",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -408,20 +385,14 @@ describe("gateway server agent", () => {
|
||||
test("agent events stream to webchat clients when run context is registered", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
@@ -66,21 +67,15 @@ describe("gateway server agent", () => {
|
||||
test("suppresses tool stream events when verbose is off", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
verboseLevel: "off",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
verboseLevel: "off",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sessionStoreSaveDelayMs,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
@@ -28,20 +29,14 @@ describe("gateway server chat", () => {
|
||||
test("chat.history caps payload bytes", { timeout: 15_000 }, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -78,22 +73,16 @@ describe("gateway server chat", () => {
|
||||
test("chat.send does not overwrite last delivery route", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -105,11 +94,12 @@ describe("gateway server chat", () => {
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as {
|
||||
main?: { lastChannel?: string; lastTo?: string };
|
||||
};
|
||||
expect(stored.main?.lastChannel).toBe("whatsapp");
|
||||
expect(stored.main?.lastTo).toBe("+1555");
|
||||
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as Record<
|
||||
string,
|
||||
{ lastChannel?: string; lastTo?: string } | undefined
|
||||
>;
|
||||
expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp");
|
||||
expect(stored["agent:main:main"]?.lastTo).toBe("+1555");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
@@ -118,20 +108,14 @@ describe("gateway server chat", () => {
|
||||
test("chat.abort cancels an in-flight chat.send", { timeout: 15000 }, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
let inFlight: Promise<unknown> | undefined;
|
||||
@@ -210,20 +194,14 @@ describe("gateway server chat", () => {
|
||||
test("chat.abort cancels while saving the session store", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
sessionStoreSaveDelayMs.value = 120;
|
||||
|
||||
@@ -288,11 +266,11 @@ describe("gateway server chat", () => {
|
||||
test("chat.send treats /stop as an out-of-band abort", { timeout: 15000 }, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify({ main: { sessionId: "sess-main", updatedAt: Date.now() } }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: { sessionId: "sess-main", updatedAt: Date.now() },
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
@@ -96,7 +97,7 @@ describe("gateway server chat", () => {
|
||||
test("chat.abort returns aborted=false for unknown runId", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(testState.sessionStorePath, JSON.stringify({}, null, 2), "utf-8");
|
||||
await writeSessionStore({ entries: {} });
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -116,20 +117,14 @@ describe("gateway server chat", () => {
|
||||
test("chat.abort rejects mismatched sessionKey", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -189,20 +184,14 @@ describe("gateway server chat", () => {
|
||||
test("chat.abort is a no-op after chat.send completes", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -259,20 +248,14 @@ describe("gateway server chat", () => {
|
||||
test("chat.send preserves run ordering for queued runs", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
@@ -106,22 +107,16 @@ describe("gateway server chat", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-discord",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "group",
|
||||
channel: "discord",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-discord",
|
||||
updatedAt: Date.now(),
|
||||
chatType: "group",
|
||||
channel: "discord",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -148,20 +143,14 @@ describe("gateway server chat", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"cron:job-1": {
|
||||
sessionId: "sess-cron",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"cron:job-1": {
|
||||
sessionId: "sess-cron",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -236,20 +225,14 @@ describe("gateway server chat", () => {
|
||||
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
@@ -349,21 +332,15 @@ describe("gateway server chat", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
sessionFile: forkedPath,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
sessionFile: forkedPath,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -397,20 +374,14 @@ describe("gateway server chat", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -447,20 +418,14 @@ describe("gateway server chat", () => {
|
||||
];
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(dir, "sess-main.jsonl"),
|
||||
JSON.stringify({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
const _decodeWsData = (data: unknown): string => {
|
||||
@@ -217,20 +218,14 @@ describe("gateway server node/bridge", () => {
|
||||
test("bridge RPC chat.history returns session messages", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(dir, "sess-main.jsonl"),
|
||||
@@ -274,20 +269,14 @@ describe("gateway server node/bridge", () => {
|
||||
test("bridge RPC sessions.list returns session rows", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
@@ -331,20 +320,14 @@ describe("gateway server node/bridge", () => {
|
||||
test("bridge chat events are pushed to subscribed nodes", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
@@ -408,20 +391,14 @@ describe("gateway server node/bridge", () => {
|
||||
test("bridge chat.send forwards image attachments to agentCommand", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
const decodeWsData = (data: unknown): string => {
|
||||
@@ -42,22 +43,16 @@ describe("gateway server node/bridge", () => {
|
||||
test("bridge voice transcript defaults to main session", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
@@ -92,20 +87,14 @@ describe("gateway server node/bridge", () => {
|
||||
test("bridge voice transcript triggers chat events for webchat clients", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
@@ -183,20 +172,14 @@ describe("gateway server node/bridge", () => {
|
||||
test("bridge chat.abort cancels while saving the session store", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testState.sessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
sessionStoreSaveDelayMs.value = 120;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
|
||||
@@ -65,41 +66,35 @@ describe("gateway server sessions", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: now - 30_000,
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
thinkingLevel: "low",
|
||||
verboseLevel: "on",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "work",
|
||||
},
|
||||
"agent:main:discord:group:dev": {
|
||||
sessionId: "sess-group",
|
||||
updatedAt: now - 120_000,
|
||||
totalTokens: 50,
|
||||
},
|
||||
"agent:main:subagent:one": {
|
||||
sessionId: "sess-subagent",
|
||||
updatedAt: now - 120_000,
|
||||
spawnedBy: "agent:main:main",
|
||||
},
|
||||
global: {
|
||||
sessionId: "sess-global",
|
||||
updatedAt: now - 10_000,
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: now - 30_000,
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
thinkingLevel: "low",
|
||||
verboseLevel: "on",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: "work",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-group",
|
||||
updatedAt: now - 120_000,
|
||||
totalTokens: 50,
|
||||
},
|
||||
"agent:main:subagent:one": {
|
||||
sessionId: "sess-subagent",
|
||||
updatedAt: now - 120_000,
|
||||
spawnedBy: "agent:main:main",
|
||||
},
|
||||
global: {
|
||||
sessionId: "sess-global",
|
||||
updatedAt: now - 10_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const hello = await connectOk(ws);
|
||||
@@ -355,21 +350,15 @@ describe("gateway server sessions", () => {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": { sessionId: "sess-main", updatedAt: Date.now() },
|
||||
"agent:main:discord:group:dev": {
|
||||
sessionId: "sess-active",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: { sessionId: "sess-main", updatedAt: Date.now() },
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-active",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
embeddedRunMock.activeIds.add("sess-active");
|
||||
embeddedRunMock.waitResults.set("sess-active", true);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
@@ -25,38 +26,30 @@ describe("gateway server sessions", () => {
|
||||
const workDir = path.join(dir, "work");
|
||||
await fs.mkdir(homeDir, { recursive: true });
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(homeDir, "sessions.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:home:main": {
|
||||
sessionId: "sess-home-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
"agent:home:discord:group:dev": {
|
||||
sessionId: "sess-home-group",
|
||||
updatedAt: Date.now() - 1000,
|
||||
},
|
||||
await writeSessionStore({
|
||||
storePath: path.join(homeDir, "sessions.json"),
|
||||
agentId: "home",
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-home-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(workDir, "sessions.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:work:main": {
|
||||
sessionId: "sess-work-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
"discord:group:dev": {
|
||||
sessionId: "sess-home-group",
|
||||
updatedAt: Date.now() - 1000,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
await writeSessionStore({
|
||||
storePath: path.join(workDir, "sessions.json"),
|
||||
agentId: "work",
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-work-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
@@ -92,20 +85,17 @@ describe("gateway server sessions", () => {
|
||||
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
|
||||
testState.sessionConfig = { mainKey: "work" };
|
||||
|
||||
await fs.writeFile(
|
||||
await writeSessionStore({
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:ops:work": {
|
||||
sessionId: "sess-ops-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
agentId: "ops",
|
||||
mainKey: "work",
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-ops-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
@@ -6,11 +6,12 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, expect } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
|
||||
import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js";
|
||||
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
|
||||
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { DEFAULT_AGENT_ID, toAgentStoreSessionKey } from "../routing/session-key.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
@@ -31,6 +32,32 @@ let previousHome: string | undefined;
|
||||
let tempHome: string | undefined;
|
||||
let tempConfigRoot: string | undefined;
|
||||
|
||||
export async function writeSessionStore(params: {
|
||||
entries: Record<string, Partial<SessionEntry>>;
|
||||
storePath?: string;
|
||||
agentId?: string;
|
||||
mainKey?: string;
|
||||
}): Promise<void> {
|
||||
const storePath = params.storePath ?? testState.sessionStorePath;
|
||||
if (!storePath) throw new Error("writeSessionStore requires testState.sessionStorePath");
|
||||
const agentId = params.agentId ?? DEFAULT_AGENT_ID;
|
||||
const store: Record<string, Partial<SessionEntry>> = {};
|
||||
for (const [requestKey, entry] of Object.entries(params.entries)) {
|
||||
const rawKey = requestKey.trim();
|
||||
const storeKey =
|
||||
rawKey === "global" || rawKey === "unknown"
|
||||
? rawKey
|
||||
: toAgentStoreSessionKey({
|
||||
agentId,
|
||||
requestKey,
|
||||
mainKey: params.mainKey,
|
||||
});
|
||||
store[storeKey] = entry;
|
||||
}
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function installGatewayTestHooks() {
|
||||
beforeEach(async () => {
|
||||
setLoggerOverride({ level: "silent", consoleLevel: "silent" });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bundled Internal Hooks
|
||||
# Bundled Hooks
|
||||
|
||||
This directory contains internal hooks that ship with Clawdbot. These hooks are automatically discovered and can be enabled/disabled via CLI or configuration.
|
||||
This directory contains hooks that ship with Clawdbot. These hooks are automatically discovered and can be enabled/disabled via CLI or configuration.
|
||||
|
||||
## Available Hooks
|
||||
|
||||
@@ -53,7 +53,7 @@ session-memory/
|
||||
---
|
||||
name: my-hook
|
||||
description: "Short description"
|
||||
homepage: https://docs.clawd.bot/hooks/my-hook
|
||||
homepage: https://docs.clawd.bot/hooks#my-hook
|
||||
metadata:
|
||||
{ "clawdbot": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
|
||||
---
|
||||
@@ -161,9 +161,9 @@ interface InternalHookEvent {
|
||||
Example handler:
|
||||
|
||||
```typescript
|
||||
import type { InternalHookHandler } from "../../src/hooks/internal-hooks.js";
|
||||
import type { HookHandler } from "../../src/hooks/hooks.js";
|
||||
|
||||
const myHandler: InternalHookHandler = async (event) => {
|
||||
const myHandler: HookHandler = async (event) => {
|
||||
if (event.type !== "command" || event.action !== "new") {
|
||||
return;
|
||||
}
|
||||
@@ -190,4 +190,4 @@ Test your hooks by:
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation: https://docs.clawd.bot/internal-hooks
|
||||
Full documentation: https://docs.clawd.bot/hooks
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: command-logger
|
||||
description: "Log all command events to a centralized audit file"
|
||||
homepage: https://docs.clawd.bot/internal-hooks#command-logger
|
||||
homepage: https://docs.clawd.bot/hooks#command-logger
|
||||
metadata:
|
||||
{
|
||||
"clawdbot":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Example internal hook handler: Log all commands to a file
|
||||
* Example hook handler: Log all commands to a file
|
||||
*
|
||||
* This handler demonstrates how to create a hook that logs all command events
|
||||
* to a centralized log file for audit/debugging purposes.
|
||||
@@ -26,12 +26,12 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import type { InternalHookHandler } from "../../internal-hooks.js";
|
||||
import type { HookHandler } from "../../hooks.js";
|
||||
|
||||
/**
|
||||
* Log all command events to a file
|
||||
*/
|
||||
const logCommand: InternalHookHandler = async (event) => {
|
||||
const logCommand: HookHandler = async (event) => {
|
||||
// Only trigger on command events
|
||||
if (event.type !== "command") {
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: session-memory
|
||||
description: "Save session context to memory when /new command is issued"
|
||||
homepage: https://docs.clawd.bot/internal-hooks#session-memory
|
||||
homepage: https://docs.clawd.bot/hooks#session-memory
|
||||
metadata:
|
||||
{
|
||||
"clawdbot":
|
||||
|
||||
@@ -11,7 +11,7 @@ import os from "node:os";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
|
||||
import type { InternalHookHandler } from "../../internal-hooks.js";
|
||||
import type { HookHandler } from "../../hooks.js";
|
||||
|
||||
/**
|
||||
* Read recent messages from session file for slug generation
|
||||
@@ -57,7 +57,7 @@ async function getRecentSessionContent(sessionFilePath: string): Promise<string
|
||||
/**
|
||||
* Save session context to memory when /new command is triggered
|
||||
*/
|
||||
const saveSessionToMemory: InternalHookHandler = async (event) => {
|
||||
const saveSessionToMemory: HookHandler = async (event) => {
|
||||
// Only trigger on 'new' command
|
||||
if (event.type !== "command" || event.action !== "new") {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hooks-e2e-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("hooks install (e2e)", () => {
|
||||
let prevStateDir: string | undefined;
|
||||
let prevBundledDir: string | undefined;
|
||||
let workspaceDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const baseDir = await makeTempDir();
|
||||
workspaceDir = path.join(baseDir, "workspace");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
prevBundledDir = process.env.CLAWDBOT_BUNDLED_HOOKS_DIR;
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(baseDir, "state");
|
||||
process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = path.join(baseDir, "bundled-none");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevStateDir === undefined) {
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
|
||||
}
|
||||
|
||||
if (prevBundledDir === undefined) {
|
||||
delete process.env.CLAWDBOT_BUNDLED_HOOKS_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_BUNDLED_HOOKS_DIR = prevBundledDir;
|
||||
}
|
||||
|
||||
vi.resetModules();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("installs a hook pack and triggers the handler", async () => {
|
||||
const baseDir = await makeTempDir();
|
||||
const packDir = path.join(baseDir, "hook-pack");
|
||||
const hookDir = path.join(packDir, "hooks", "hello-hook");
|
||||
await fs.mkdir(hookDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(packDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "@acme/hello-hooks",
|
||||
version: "0.0.0",
|
||||
clawdbot: { hooks: ["./hooks/hello-hook"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(hookDir, "HOOK.md"),
|
||||
[
|
||||
"---",
|
||||
'name: "hello-hook"',
|
||||
'description: "Test hook"',
|
||||
'metadata: {"clawdbot":{"events":["command:new"]}}',
|
||||
"---",
|
||||
"",
|
||||
"# Hello Hook",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(hookDir, "handler.js"),
|
||||
"export default async function(event) { event.messages.push('hook-ok'); }\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { installHooksFromPath } = await import("./install.js");
|
||||
const installResult = await installHooksFromPath({ path: packDir });
|
||||
expect(installResult.ok).toBe(true);
|
||||
if (!installResult.ok) return;
|
||||
|
||||
const { clearInternalHooks, createInternalHookEvent, triggerInternalHook } =
|
||||
await import("./internal-hooks.js");
|
||||
const { loadInternalHooks } = await import("./loader.js");
|
||||
|
||||
clearInternalHooks();
|
||||
const loaded = await loadInternalHooks(
|
||||
{ hooks: { internal: { enabled: true } } },
|
||||
workspaceDir,
|
||||
);
|
||||
expect(loaded).toBe(1);
|
||||
|
||||
const event = createInternalHookEvent("command", "new", "test-session");
|
||||
await triggerInternalHook(event);
|
||||
expect(event.messages).toContain("hook-ok");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from "./internal-hooks.js";
|
||||
|
||||
export type HookEventType = import("./internal-hooks.js").InternalHookEventType;
|
||||
export type HookEvent = import("./internal-hooks.js").InternalHookEvent;
|
||||
export type HookHandler = import("./internal-hooks.js").InternalHookHandler;
|
||||
|
||||
export {
|
||||
registerInternalHook as registerHook,
|
||||
unregisterInternalHook as unregisterHook,
|
||||
clearInternalHooks as clearHooks,
|
||||
getRegisteredEventKeys as getRegisteredHookEventKeys,
|
||||
triggerInternalHook as triggerHook,
|
||||
createInternalHookEvent as createHookEvent,
|
||||
} from "./internal-hooks.js";
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import * as tar from "tar";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -61,7 +62,7 @@ describe("installHooksFromArchive", () => {
|
||||
"---",
|
||||
"name: zip-hook",
|
||||
"description: Zip hook",
|
||||
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
|
||||
'metadata: {"clawdbot":{"events":["command:new"]}}',
|
||||
"---",
|
||||
"",
|
||||
"# Zip Hook",
|
||||
@@ -81,9 +82,55 @@ describe("installHooksFromArchive", () => {
|
||||
expect(result.hookPackId).toBe("zip-hooks");
|
||||
expect(result.hooks).toContain("zip-hook");
|
||||
expect(result.targetDir).toBe(path.join(stateDir, "hooks", "zip-hooks"));
|
||||
expect(
|
||||
fs.existsSync(path.join(result.targetDir, "hooks", "zip-hook", "HOOK.md")),
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(result.targetDir, "hooks", "zip-hook", "HOOK.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("installs hook packs from tar archives", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const workDir = makeTempDir();
|
||||
const archivePath = path.join(workDir, "hooks.tar");
|
||||
const pkgDir = path.join(workDir, "package");
|
||||
|
||||
fs.mkdirSync(path.join(pkgDir, "hooks", "tar-hook"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pkgDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@clawdbot/tar-hooks",
|
||||
version: "0.0.1",
|
||||
clawdbot: { hooks: ["./hooks/tar-hook"] },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pkgDir, "hooks", "tar-hook", "HOOK.md"),
|
||||
[
|
||||
"---",
|
||||
"name: tar-hook",
|
||||
"description: Tar hook",
|
||||
'metadata: {"clawdbot":{"events":["command:new"]}}',
|
||||
"---",
|
||||
"",
|
||||
"# Tar Hook",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pkgDir, "hooks", "tar-hook", "handler.ts"),
|
||||
"export default async () => {};\n",
|
||||
"utf-8",
|
||||
);
|
||||
await tar.c({ cwd: workDir, file: archivePath }, ["package"]);
|
||||
|
||||
const result = await withStateDir(stateDir, async () => {
|
||||
const { installHooksFromArchive } = await import("./install.js");
|
||||
return await installHooksFromArchive({ archivePath });
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.hookPackId).toBe("tar-hooks");
|
||||
expect(result.hooks).toContain("tar-hook");
|
||||
expect(result.targetDir).toBe(path.join(stateDir, "hooks", "tar-hooks"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,7 +146,7 @@ describe("installHooksFromPath", () => {
|
||||
"---",
|
||||
"name: my-hook",
|
||||
"description: My hook",
|
||||
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
|
||||
'metadata: {"clawdbot":{"events":["command:new"]}}',
|
||||
"---",
|
||||
"",
|
||||
"# My Hook",
|
||||
|
||||
@@ -134,7 +134,9 @@ async function installHookPackageFromDir(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const hooksDir = params.hooksDir ? resolveUserPath(params.hooksDir) : path.join(CONFIG_DIR, "hooks");
|
||||
const hooksDir = params.hooksDir
|
||||
? resolveUserPath(params.hooksDir)
|
||||
: path.join(CONFIG_DIR, "hooks");
|
||||
await fs.mkdir(hooksDir, { recursive: true });
|
||||
|
||||
const targetDir = resolveHookInstallDir(hookPackId, hooksDir);
|
||||
@@ -232,7 +234,9 @@ async function installHookFromDir(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const hooksDir = params.hooksDir ? resolveUserPath(params.hooksDir) : path.join(CONFIG_DIR, "hooks");
|
||||
const hooksDir = params.hooksDir
|
||||
? resolveUserPath(params.hooksDir)
|
||||
: path.join(CONFIG_DIR, "hooks");
|
||||
await fs.mkdir(hooksDir, { recursive: true });
|
||||
|
||||
const targetDir = resolveHookInstallDir(hookName, hooksDir);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type InternalHookEvent,
|
||||
} from "./internal-hooks.js";
|
||||
|
||||
describe("internal-hooks", () => {
|
||||
describe("hooks", () => {
|
||||
beforeEach(() => {
|
||||
clearInternalHooks();
|
||||
});
|
||||
@@ -131,7 +131,7 @@ describe("internal-hooks", () => {
|
||||
expect(errorHandler).toHaveBeenCalled();
|
||||
expect(successHandler).toHaveBeenCalled();
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Internal hook error"),
|
||||
expect.stringContaining("Hook error"),
|
||||
expect.stringContaining("Handler failed"),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Internal hook system for clawdbot agent events
|
||||
* Hook system for clawdbot agent events
|
||||
*
|
||||
* Provides an extensible event-driven hook system for internal agent events
|
||||
* Provides an extensible event-driven hook system for agent events
|
||||
* like command processing, session lifecycle, etc.
|
||||
*/
|
||||
|
||||
@@ -91,7 +91,7 @@ export function getRegisteredEventKeys(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an internal hook event
|
||||
* Trigger a hook event
|
||||
*
|
||||
* Calls all handlers registered for:
|
||||
* 1. The general event type (e.g., 'command')
|
||||
@@ -117,7 +117,7 @@ export async function triggerInternalHook(event: InternalHookEvent): Promise<voi
|
||||
await handler(event);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Internal hook error [${event.type}:${event.action}]:`,
|
||||
`Hook error [${event.type}:${event.action}]:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export async function triggerInternalHook(event: InternalHookEvent): Promise<voi
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an internal hook event with common fields filled in
|
||||
* Create a hook event with common fields filled in
|
||||
*
|
||||
* @param type - The event type
|
||||
* @param action - The action within that type
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("loader", () => {
|
||||
});
|
||||
|
||||
describe("loadInternalHooks", () => {
|
||||
it("should return 0 when internal hooks are not enabled", async () => {
|
||||
it("should return 0 when hooks are not enabled", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
hooks: {
|
||||
internal: {
|
||||
@@ -170,7 +170,7 @@ describe("loader", () => {
|
||||
const count = await loadInternalHooks(cfg, tmpDir);
|
||||
expect(count).toBe(0);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to load internal hook handler"),
|
||||
expect.stringContaining("Failed to load hook handler"),
|
||||
expect.any(String),
|
||||
);
|
||||
|
||||
|
||||
+11
-15
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Dynamic loader for internal hook handlers
|
||||
* Dynamic loader for hook handlers
|
||||
*
|
||||
* Loads hook handlers from external modules based on configuration
|
||||
* and from directory-based discovery (bundled, managed, workspace)
|
||||
@@ -15,7 +15,7 @@ import { resolveHookConfig } from "./config.js";
|
||||
import { shouldIncludeHook } from "./config.js";
|
||||
|
||||
/**
|
||||
* Load and register all internal hook handlers
|
||||
* Load and register all hook handlers
|
||||
*
|
||||
* Loads hooks from both:
|
||||
* 1. Directory-based discovery (bundled, managed, workspace)
|
||||
@@ -30,14 +30,14 @@ import { shouldIncludeHook } from "./config.js";
|
||||
* const config = await loadConfig();
|
||||
* const workspaceDir = resolveAgentWorkspaceDir(config, agentId);
|
||||
* const count = await loadInternalHooks(config, workspaceDir);
|
||||
* console.log(`Loaded ${count} internal hook handlers`);
|
||||
* console.log(`Loaded ${count} hook handlers`);
|
||||
* ```
|
||||
*/
|
||||
export async function loadInternalHooks(
|
||||
cfg: ClawdbotConfig,
|
||||
workspaceDir: string,
|
||||
): Promise<number> {
|
||||
// Check if internal hooks are enabled
|
||||
// Check if hooks are enabled
|
||||
if (!cfg.hooks?.internal?.enabled) {
|
||||
return 0;
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export async function loadInternalHooks(
|
||||
|
||||
if (typeof handler !== "function") {
|
||||
console.error(
|
||||
`Internal hook error: Handler '${exportName}' from ${entry.hook.name} is not a function`,
|
||||
`Hook error: Handler '${exportName}' from ${entry.hook.name} is not a function`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -79,9 +79,7 @@ export async function loadInternalHooks(
|
||||
// Register for all events listed in metadata
|
||||
const events = entry.clawdbot?.events ?? [];
|
||||
if (events.length === 0) {
|
||||
console.warn(
|
||||
`Internal hook warning: Hook '${entry.hook.name}' has no events defined in metadata`,
|
||||
);
|
||||
console.warn(`Hook warning: Hook '${entry.hook.name}' has no events defined in metadata`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -90,12 +88,12 @@ export async function loadInternalHooks(
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Registered internal hook: ${entry.hook.name} -> ${events.join(", ")}${exportName !== "default" ? ` (export: ${exportName})` : ""}`,
|
||||
`Registered hook: ${entry.hook.name} -> ${events.join(", ")}${exportName !== "default" ? ` (export: ${exportName})` : ""}`,
|
||||
);
|
||||
loadedCount++;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to load internal hook ${entry.hook.name}:`,
|
||||
`Failed to load hook ${entry.hook.name}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
@@ -126,21 +124,19 @@ export async function loadInternalHooks(
|
||||
const handler = mod[exportName];
|
||||
|
||||
if (typeof handler !== "function") {
|
||||
console.error(
|
||||
`Internal hook error: Handler '${exportName}' from ${modulePath} is not a function`,
|
||||
);
|
||||
console.error(`Hook error: Handler '${exportName}' from ${modulePath} is not a function`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Register the handler
|
||||
registerInternalHook(handlerConfig.event, handler as InternalHookHandler);
|
||||
console.log(
|
||||
`Registered internal hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== "default" ? `#${exportName}` : ""}`,
|
||||
`Registered hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== "default" ? `#${exportName}` : ""}`,
|
||||
);
|
||||
loadedCount++;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to load internal hook handler from ${handlerConfig.module}:`,
|
||||
`Failed to load hook handler from ${handlerConfig.module}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ function resolvePackageHooks(manifest: HookPackageManifest): string[] {
|
||||
return raw.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function loadHookFromDir(params: { hookDir: string; source: string; nameHint?: string }): Hook | null {
|
||||
function loadHookFromDir(params: {
|
||||
hookDir: string;
|
||||
source: string;
|
||||
nameHint?: string;
|
||||
}): Hook | null {
|
||||
const hookMdPath = path.join(params.hookDir, "HOOK.md");
|
||||
if (!fs.existsSync(hookMdPath)) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSZip from "jszip";
|
||||
import * as tar from "tar";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { extractArchive, resolveArchiveKind, resolvePackedRootDir } from "./archive.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-archive-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("archive utils", () => {
|
||||
it("detects archive kinds", () => {
|
||||
expect(resolveArchiveKind("/tmp/file.zip")).toBe("zip");
|
||||
expect(resolveArchiveKind("/tmp/file.tgz")).toBe("tar");
|
||||
expect(resolveArchiveKind("/tmp/file.tar.gz")).toBe("tar");
|
||||
expect(resolveArchiveKind("/tmp/file.tar")).toBe("tar");
|
||||
expect(resolveArchiveKind("/tmp/file.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts zip archives", async () => {
|
||||
const workDir = await makeTempDir();
|
||||
const archivePath = path.join(workDir, "bundle.zip");
|
||||
const extractDir = path.join(workDir, "extract");
|
||||
|
||||
const zip = new JSZip();
|
||||
zip.file("package/hello.txt", "hi");
|
||||
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
|
||||
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
await extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 });
|
||||
const rootDir = await resolvePackedRootDir(extractDir);
|
||||
const content = await fs.readFile(path.join(rootDir, "hello.txt"), "utf-8");
|
||||
expect(content).toBe("hi");
|
||||
});
|
||||
|
||||
it("extracts tar archives", async () => {
|
||||
const workDir = await makeTempDir();
|
||||
const archivePath = path.join(workDir, "bundle.tar");
|
||||
const extractDir = path.join(workDir, "extract");
|
||||
const packageDir = path.join(workDir, "package");
|
||||
|
||||
await fs.mkdir(packageDir, { recursive: true });
|
||||
await fs.writeFile(path.join(packageDir, "hello.txt"), "yo");
|
||||
await tar.c({ cwd: workDir, file: archivePath }, ["package"]);
|
||||
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
await extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 });
|
||||
const rootDir = await resolvePackedRootDir(extractDir);
|
||||
const content = await fs.readFile(path.join(rootDir, "hello.txt"), "utf-8");
|
||||
expect(content).toBe("yo");
|
||||
});
|
||||
});
|
||||
@@ -61,10 +61,7 @@ export async function withTimeout<T>(
|
||||
}
|
||||
}
|
||||
|
||||
async function extractZip(params: {
|
||||
archivePath: string;
|
||||
destDir: string;
|
||||
}): Promise<void> {
|
||||
async function extractZip(params: { archivePath: string; destDir: string }): Promise<void> {
|
||||
const buffer = await fs.readFile(params.archivePath);
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
const entries = Object.values(zip.files);
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
normalizeMessageChannel,
|
||||
type GatewayMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { resolveOutboundTarget, resolveSessionDeliveryTarget, type SessionDeliveryTarget } from "./targets.js";
|
||||
import {
|
||||
resolveOutboundTarget,
|
||||
resolveSessionDeliveryTarget,
|
||||
type SessionDeliveryTarget,
|
||||
} from "./targets.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { OutboundTargetResolution } from "./targets.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export function missingTargetMessage(provider: string, hint?: string): string {
|
||||
return `Delivering to ${provider} requires target${formatHint(hint)}`;
|
||||
return `Delivering to ${provider} requires target${formatTargetHint(hint)}`;
|
||||
}
|
||||
|
||||
export function missingTargetError(provider: string, hint?: string): Error {
|
||||
@@ -7,7 +7,7 @@ export function missingTargetError(provider: string, hint?: string): Error {
|
||||
}
|
||||
|
||||
export function ambiguousTargetMessage(provider: string, raw: string, hint?: string): string {
|
||||
return `Ambiguous target "${raw}" for ${provider}. Provide a unique name or an explicit id.${formatHint(hint, true)}`;
|
||||
return `Ambiguous target "${raw}" for ${provider}. Provide a unique name or an explicit id.${formatTargetHint(hint, true)}`;
|
||||
}
|
||||
|
||||
export function ambiguousTargetError(provider: string, raw: string, hint?: string): Error {
|
||||
@@ -15,14 +15,14 @@ export function ambiguousTargetError(provider: string, raw: string, hint?: strin
|
||||
}
|
||||
|
||||
export function unknownTargetMessage(provider: string, raw: string, hint?: string): string {
|
||||
return `Unknown target "${raw}" for ${provider}.${formatHint(hint, true)}`;
|
||||
return `Unknown target "${raw}" for ${provider}.${formatTargetHint(hint, true)}`;
|
||||
}
|
||||
|
||||
export function unknownTargetError(provider: string, raw: string, hint?: string): Error {
|
||||
return new Error(unknownTargetMessage(provider, raw, hint));
|
||||
}
|
||||
|
||||
function formatHint(hint?: string, withLabel = false): string {
|
||||
function formatTargetHint(hint?: string, withLabel = false): string {
|
||||
if (!hint) return "";
|
||||
return withLabel ? ` Hint: ${hint}` : ` ${hint}`;
|
||||
}
|
||||
@@ -16,8 +16,9 @@ export function normalizeTargetForProvider(provider: string, raw?: string): stri
|
||||
|
||||
export function buildTargetResolverSignature(channel: ChannelId): string {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
const hint = plugin?.messaging?.targetHint ?? "";
|
||||
const looksLike = plugin?.messaging?.looksLikeTargetId;
|
||||
const resolver = plugin?.messaging?.targetResolver;
|
||||
const hint = resolver?.hint ?? "";
|
||||
const looksLike = resolver?.looksLikeId;
|
||||
const source = looksLike ? looksLike.toString() : "";
|
||||
return hashSignature(`${hint}|${source}`);
|
||||
}
|
||||
|
||||
@@ -164,24 +164,6 @@ function resolveMatch(params: {
|
||||
return { kind: "ambiguous" as const, entries: matches };
|
||||
}
|
||||
|
||||
function looksLikeTargetId(params: {
|
||||
channel: ChannelId;
|
||||
raw: string;
|
||||
normalized: string;
|
||||
}): boolean {
|
||||
const raw = params.raw.trim();
|
||||
if (!raw) return false;
|
||||
const plugin = getChannelPlugin(params.channel);
|
||||
const lookup = plugin?.messaging?.looksLikeTargetId;
|
||||
if (lookup) return lookup(raw, params.normalized);
|
||||
if (/^(channel|group|user):/i.test(raw)) return true;
|
||||
if (/^[@#]/.test(raw)) return true;
|
||||
if (/^\+?\d{6,}$/.test(raw)) return true;
|
||||
if (raw.includes("@thread")) return true;
|
||||
if (/^(conversation|user):/i.test(raw)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function listDirectoryEntries(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: ChannelId;
|
||||
@@ -285,10 +267,22 @@ export async function resolveMessagingTarget(params: {
|
||||
}
|
||||
const plugin = getChannelPlugin(params.channel);
|
||||
const providerLabel = plugin?.meta?.label ?? params.channel;
|
||||
const hint = plugin?.messaging?.targetHint;
|
||||
const hint = plugin?.messaging?.targetResolver?.hint;
|
||||
const kind = detectTargetKind(raw, params.preferredKind);
|
||||
const normalized = normalizeTargetForProvider(params.channel, raw) ?? raw;
|
||||
if (looksLikeTargetId({ channel: params.channel, raw, normalized })) {
|
||||
const looksLikeTargetId = (): boolean => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
const lookup = plugin?.messaging?.targetResolver?.looksLikeId;
|
||||
if (lookup) return lookup(trimmed, normalized);
|
||||
if (/^(channel|group|user):/i.test(trimmed)) return true;
|
||||
if (/^[@#]/.test(trimmed)) return true;
|
||||
if (/^\+?\d{6,}$/.test(trimmed)) return true;
|
||||
if (trimmed.includes("@thread")) return true;
|
||||
if (/^(conversation|user):/i.test(trimmed)) return true;
|
||||
return false;
|
||||
};
|
||||
if (looksLikeTargetId()) {
|
||||
const directTarget = preserveTargetCase(params.channel, raw, normalized);
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -146,7 +146,7 @@ export function resolveOutboundTarget(params: {
|
||||
if (trimmed) {
|
||||
return { ok: true, to: trimmed };
|
||||
}
|
||||
const hint = plugin.messaging?.targetHint;
|
||||
const hint = plugin.messaging?.targetResolver?.hint;
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError(plugin.meta.label ?? params.channel, hint),
|
||||
|
||||
@@ -121,9 +121,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
|
||||
expect(result.appliedAudio).toBe(true);
|
||||
expect(ctx.Transcript).toBe("transcribed text");
|
||||
expect(ctx.Body).toBe(
|
||||
"[Audio]\nUser text:\n/capture status\nTranscript:\ntranscribed text",
|
||||
);
|
||||
expect(ctx.Body).toBe("[Audio]\nUser text:\n/capture status\nTranscript:\ntranscribed text");
|
||||
expect(ctx.CommandBody).toBe("/capture status");
|
||||
expect(ctx.RawBody).toBe("/capture status");
|
||||
expect(ctx.BodyForCommands).toBe("/capture status");
|
||||
@@ -424,4 +422,64 @@ describe("applyMediaUnderstanding", () => {
|
||||
["[Audio 1/2]\nTranscript:\nnote-a.ogg", "[Audio 2/2]\nTranscript:\nnote-b.ogg"].join("\n\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("orders mixed media outputs as image, audio, video", async () => {
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-media-"));
|
||||
const imagePath = path.join(dir, "photo.jpg");
|
||||
const audioPath = path.join(dir, "note.ogg");
|
||||
const videoPath = path.join(dir, "clip.mp4");
|
||||
await fs.writeFile(imagePath, "image-bytes");
|
||||
await fs.writeFile(audioPath, "audio-bytes");
|
||||
await fs.writeFile(videoPath, "video-bytes");
|
||||
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:mixed>",
|
||||
MediaPaths: [imagePath, audioPath, videoPath],
|
||||
MediaTypes: ["image/jpeg", "audio/ogg", "video/mp4"],
|
||||
};
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: {
|
||||
media: {
|
||||
image: { enabled: true, models: [{ provider: "openai", model: "gpt-5.2" }] },
|
||||
audio: { enabled: true, models: [{ provider: "groq" }] },
|
||||
video: { enabled: true, models: [{ provider: "google", model: "gemini-3" }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applyMediaUnderstanding({
|
||||
ctx,
|
||||
cfg,
|
||||
agentDir: dir,
|
||||
providers: {
|
||||
openai: {
|
||||
id: "openai",
|
||||
describeImage: async () => ({ text: "image ok" }),
|
||||
},
|
||||
groq: {
|
||||
id: "groq",
|
||||
transcribeAudio: async () => ({ text: "audio ok" }),
|
||||
},
|
||||
google: {
|
||||
id: "google",
|
||||
describeVideo: async () => ({ text: "video ok" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.appliedImage).toBe(true);
|
||||
expect(result.appliedAudio).toBe(true);
|
||||
expect(result.appliedVideo).toBe(true);
|
||||
expect(ctx.Body).toBe(
|
||||
[
|
||||
"[Image]\nDescription:\nimage ok",
|
||||
"[Audio]\nTranscript:\naudio ok",
|
||||
"[Video]\nDescription:\nvideo ok",
|
||||
].join("\n\n"),
|
||||
);
|
||||
expect(ctx.Transcript).toBe("audio ok");
|
||||
expect(ctx.CommandBody).toBe("audio ok");
|
||||
expect(ctx.BodyForCommands).toBe("audio ok");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user