Compare commits

...
Author SHA1 Message Date
Peter Steinberger 504f2b0606 fix: refresh TUI tokens after runs (#1079) (thanks @d-ploutarchos) 2026-01-17 08:16:36 +00:00
Dimitrios Ploutarchos e10e81f579 TUI: refresh token counts after agent runs complete. Closes #1078 2026-01-17 08:11:33 +00:00
Peter Steinberger c5239f6a8e fix: stabilize pty tests and media kind 2026-01-17 08:10:44 +00:00
Peter Steinberger cccd7c7b8e test: stabilize windows pty expectations 2026-01-17 08:07:31 +00:00
Peter Steinberger 8f1132e8ec fix: share skills watcher ignores 2026-01-17 08:07:06 +00:00
Peter Steinberger e6477363e9 refactor: normalize channel capabilities typing 2026-01-17 08:06:35 +00:00
Peter Steinberger 1a4fc8dea6 fix: guard memory sync errors 2026-01-17 08:04:48 +00:00
Peter Steinberger a3daf3d115 style: oxfmt 2026-01-17 08:01:46 +00:00
Peter Steinbergeranddanielz1z f3f80509e3 test: cover tg/group/topic inline button targets (#1072) — thanks @danielz1z
Co-authored-by: danielz1z <danielz1z@users.noreply.github.com>
2026-01-17 08:00:16 +00:00
Peter Steinberger 7cebe7a506 style: run oxfmt 2026-01-17 08:00:05 +00:00
Peter Steinberger 5986175268 fix: restore CI lint/build 2026-01-17 08:00:05 +00:00
Peter Steinberger 7630c6dccb Merge pull request #1074 from roshanasingh4/fix/1056-ignore-heavy-watch-paths
Fix #1056: prevent macOS FD exhaustion by ignoring node_modules in skills watcher
2026-01-17 07:56:54 +00:00
Peter Steinbergeranddanielz1z d63cc1e8a7 fix: allow telegram prefixes/topics for inline buttons (#1072) — thanks @danielz1z
Co-authored-by: danielz1z <danielz1z@users.noreply.github.com>
2026-01-17 07:49:57 +00:00
danielz1z 80bb6b712c fix: handle telegram: prefix in resolveTelegramTargetChatType
When using inlineButtons="dm" or "group" scope, the validation check
resolveTelegramTargetChatType() failed for numeric chat IDs because
normalizeTelegramMessagingTarget() adds a "telegram:" prefix during
target resolution.

For example, target "5232990709" becomes "telegram:5232990709" after
normalization, but the regex /^-?\d+$/ expects a pure numeric string.

The fix strips the telegram: prefix before checking the numeric pattern.

Adds tests for resolveTelegramTargetChatType with various input formats.
2026-01-17 07:47:14 +00:00
Peter SteinbergerandRoshan Singh 410b8f223e fix: keep extension relay list current (#1073)
Thanks @roshanasingh4.

Co-authored-by: Roshan Singh <88576930+roshanasingh4@users.noreply.github.com>
2026-01-17 07:43:31 +00:00
Roshan Singh 693f152895 Fix #1035: refresh extension tab metadata
Handle Target.targetInfoChanged in extension relay so /json/list reflects updated title/url after navigation. Adds regression coverage.
2026-01-17 07:43:09 +00:00
Peter Steinberger 78a4441ac2 test: stabilize bash send-keys submit 2026-01-17 07:41:24 +00:00
Peter Steinberger c92265a51b refactor: canonicalize gateway session store keys 2026-01-17 07:41:24 +00:00
Peter Steinberger d5fdda8e28 refactor: prune room legacy 2026-01-17 07:41:24 +00:00
Peter Steinberger 6d969fe58e refactor: normalize media attachment selection 2026-01-17 07:38:11 +00:00
Peter Steinberger 68c7d577a4 chore: drop target format helper 2026-01-17 07:36:13 +00:00
Peter Steinberger 1ea8917e2b refactor: trim resolver exports 2026-01-17 07:36:09 +00:00
Peter Steinberger 07c93dfd30 refactor: streamline target resolver helpers 2026-01-17 07:34:26 +00:00
Peter Steinberger cf0ea6c756 refactor: unify target resolver metadata 2026-01-17 07:34:26 +00:00
Peter SteinbergerandAdam Holt 8c9e32c4a3 refactor: share sessions list row type
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 07:34:21 +00:00
Peter Steinberger 34d59d7913 refactor: rename hooks docs and add tests 2026-01-17 07:32:54 +00:00
Peter Steinberger 0c0d9e1d22 Merge pull request #1071 from danielz1z/fix/capabilities-object-format
fix: handle object-format capabilities in normalizeCapabilities
2026-01-17 07:31:52 +00:00
Peter Steinberger 2ee45d50a4 refactor: tighten media diagnostics 2026-01-17 07:27:38 +00:00
Peter Steinberger 0c0e1e4226 refactor: extend media understanding 2026-01-17 07:17:13 +00:00
Roshan Singh e7953d8164 Fix #1056: ignore heavy paths in skills watcher
On macOS, watching deep dependency trees can exhaust file descriptors and lead to spawn EBADF failures. The skills watcher only needs to observe skill changes, so ignore dotfiles, node_modules, and dist by default. Adds regression coverage.
2026-01-17 06:26:27 +00:00
danielz1z f42b12646d fix: handle object-format capabilities in normalizeCapabilities
When capabilities is configured as an object (e.g., { inlineButtons: "dm" })
instead of a string array, normalizeCapabilities() would crash with
"capabilities.map is not a function".

This can occur when using the new Telegram inline buttons scoping feature:
  channels.telegram.capabilities.inlineButtons = "dm"

The fix adds an Array.isArray() guard to return undefined for non-array
capabilities, allowing channel-specific handlers (like
resolveTelegramInlineButtonsScope) to process the object format separately.

Fixes crash when using object-format TelegramCapabilitiesConfig.
2026-01-17 05:11:57 +00:00
130 changed files with 2773 additions and 1828 deletions

No files matched your search

+6 -2
View File
@@ -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
View File
@@ -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)
+21 -21
View File
@@ -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'
});
+9
View File
@@ -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.
+8 -6
View File
@@ -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,
+3 -3
View File
@@ -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,
+8 -6
View File
@@ -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,
+7 -5
View File
@@ -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,
+7 -5
View File
@@ -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 }) => {
+1 -1
View File
@@ -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 };
+2 -7
View File
@@ -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: [
+2 -1
View File
@@ -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;
+6 -1
View File
@@ -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"] });
+31
View File
@@ -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);
});
});
+9
View File
@@ -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 };
+30
View File
@@ -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;
+4 -28
View File
@@ -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);
+36
View File
@@ -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: {
+65
View File
@@ -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]");
});
});
+16 -5
View File
@@ -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
+1
View File
@@ -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 };
};
+3
View File
@@ -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,
});
+1 -1
View File
@@ -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 };
+1 -3
View File
@@ -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,
+1 -1
View File
@@ -23,7 +23,7 @@ describe("resolveGroupRequireMention", () => {
const ctx: TemplateContext = {
Provider: "discord",
From: "group:123",
GroupRoom: "#general",
GroupChannel: "#general",
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
+4 -4
View File
@@ -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 -4
View File
@@ -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";
+10 -7
View File
@@ -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;
+53
View File
@@ -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" },
+40
View File
@@ -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}`,
+3 -2
View File
@@ -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[];
+56
View File
@@ -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);
+17
View File
@@ -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 });
}
});
-1
View File
@@ -70,4 +70,3 @@ describe("resolveCommandAuthorizedFromAuthorizers", () => {
).toBe(true);
});
});
-1
View File
@@ -21,4 +21,3 @@ export function resolveCommandAuthorizedFromAuthorizers(params: {
}
return authorizers.some((entry) => entry.configured && entry.allowed);
}
+1 -1
View File
@@ -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() ||
+18 -24
View File
@@ -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"]);
});
+214
View File
@@ -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);
}
+11 -74
View File
@@ -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 -4
View File
@@ -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 ?? "",
+9 -7
View File
@@ -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),
+10
View File
@@ -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";
+5 -6
View File
@@ -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),
+10 -53
View File
@@ -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 }) => {
+11 -45
View File
@@ -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: {
+6 -3
View File
@@ -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";
+11 -31
View File
@@ -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 }) => {
+3 -1
View File
@@ -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);
});
+54
View File
@@ -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
View File
@@ -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",
+3 -1
View File
@@ -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
+1 -4
View File
@@ -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),
}));
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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) => ({
+26 -6
View File
@@ -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 => ({
+15 -7
View File
@@ -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({
+1 -1
View File
@@ -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[];
+1 -5
View File
@@ -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(),
})
+1 -5
View File
@@ -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,
+2 -3
View File
@@ -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;
}
+1 -1
View File
@@ -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);
+28 -1
View File
@@ -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" });
+6 -6
View File
@@ -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 -1
View File
@@ -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":
+3 -3
View File
@@ -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 -1
View File
@@ -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":
+2 -2
View File
@@ -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;
+115
View File
@@ -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");
});
});
+14
View File
@@ -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";
+52 -5
View File
@@ -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",
+6 -2
View File
@@ -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);
+2 -2
View File
@@ -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"),
);
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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),
);
}
+5 -1
View File
@@ -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;
+68
View File
@@ -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");
});
});
+1 -4
View File
@@ -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);
+5 -1
View File
@@ -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";
+4 -4
View File
@@ -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}`;
}
+3 -2
View File
@@ -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}`);
}
+14 -20
View File
@@ -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,
+1 -1
View File
@@ -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),
+61 -3
View File
@@ -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