Compare commits

..
Author SHA1 Message Date
Peter Steinberger 3ab4c3a3c4 fix: add object capabilities coverage (#1071) (thanks @danielz1z) 2026-01-17 07:31:19 +00:00
danielz1z be6536a635 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 07:23:16 +00:00
Peter SteinbergerandAdam Holt c2e10710f4 refactor: share sessions list row type
Co-authored-by: Adam Holt <mail@adamholt.co.nz>
2026-01-17 07:23:08 +00:00
128 changed files with 1825 additions and 2716 deletions

No files matched your search

+3 -6
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 hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
- Hooks: add internal 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`; hooks live under `clawdbot hooks`.
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; internal hooks live under `clawdbot hooks`.
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
### Changes
@@ -27,7 +27,6 @@
- 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.
@@ -44,11 +43,10 @@
- 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.
- Config: handle object-format Telegram capabilities in channel capability resolution. (#1071) — thanks @danielz1z.
- 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.
@@ -61,7 +59,6 @@
- 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` (agent hooks)"
summary: "CLI reference for `clawdbot hooks` (internal hooks)"
read_when:
- You want to manage agent hooks
- You want to install or update hooks
- You want to manage internal agent hooks
- You want to install or update internal hooks
---
# `clawdbot hooks`
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Related:
- Hooks: [Hooks](/hooks)
- Internal Hooks: [Internal Agent Hooks](/internal-hooks)
## List All Hooks
@@ -18,7 +18,7 @@ Related:
clawdbot hooks list
```
List all discovered hooks from workspace, managed, and bundled directories.
List all discovered internal hooks from workspace, managed, and bundled directories.
**Options:**
- `--eligible`: Show only eligible hooks (requirements met)
@@ -28,7 +28,7 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:**
```
Hooks (2/2 ready)
Internal 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/hooks#session-memory
Homepage: https://docs.clawd.bot/internal-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:**
```
Hooks Status
Internal 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](/hooks#session-memory)
**See:** [session-memory documentation](/internal-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](/hooks#command-logger)
**See:** [command-logger documentation](/internal-hooks#command-logger)
+21 -21
View File
@@ -1,19 +1,19 @@
---
summary: "Hooks: event-driven automation for commands and lifecycle events"
summary: "Internal agent 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 hooks
- You want to build, install, or debug internal hooks
---
# Hooks
# Internal Agent Hooks
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.
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.
## Getting Oriented
Hooks are small scripts that run when something happens. There are two kinds:
- **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.
- **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.
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 a 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 an internal hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
## Overview
The hooks system allows you to:
The internal 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/hooks#my-hook
homepage: https://docs.clawd.bot/internal-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 a `HookHandler` function:
The `handler.ts` file exports an `InternalHookHandler` function:
```typescript
import type { HookHandler } from '../../src/hooks/hooks.js';
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
const myHandler: HookHandler = async (event) => {
const myHandler: InternalHookHandler = 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 { HookHandler } from '../../src/hooks/hooks.js';
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
const handler: HookHandler = async (event) => {
const handler: InternalHookHandler = 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: HookHandler = async (event) => {
const handler: InternalHookHandler = async (event) => {
void processInBackground(event); // Fire and forget
};
// ✗ Bad - blocks command processing
const handler: HookHandler = async (event) => {
const handler: InternalHookHandler = async (event) => {
await slowDatabaseQuery(event);
await evenSlowerAPICall(event);
};
@@ -521,7 +521,7 @@ const handler: HookHandler = async (event) => {
Always wrap risky operations:
```typescript
const handler: HookHandler = async (event) => {
const handler: InternalHookHandler = async (event) => {
try {
await riskyOperation(event);
} catch (err) {
@@ -536,7 +536,7 @@ const handler: HookHandler = async (event) => {
Return early if the event isn't relevant:
```typescript
const handler: HookHandler = async (event) => {
const handler: InternalHookHandler = 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: HookHandler = async (event) => {
const handler: InternalHookHandler = 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 { createHookEvent } from './src/hooks/hooks.js';
import { createInternalHookEvent } from './src/hooks/internal-hooks.js';
import myHandler from './hooks/my-hook/handler.js';
test('my handler works', async () => {
const event = createHookEvent('command', 'new', 'test-session', {
const event = createInternalHookEvent('command', 'new', 'test-session', {
foo: 'bar'
});
-9
View File
@@ -256,15 +256,6 @@ 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.
+6 -8
View File
@@ -164,15 +164,13 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
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>",
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
return trimmed.includes(":");
},
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 groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : [];
const groupRoom = params.groupRoom?.trim() ?? "";
const aliases = groupRoom ? [groupRoom] : [];
const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.rooms,
roomId,
aliases,
name: groupChannel || undefined,
name: groupRoom || 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,
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
GroupRoom: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
Provider: "matrix" as const,
Surface: "matrix" as const,
+6 -8
View File
@@ -133,15 +133,13 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
},
messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget,
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>",
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(conversation:|user:)/i.test(trimmed)) return true;
return trimmed.includes("@thread");
},
targetHint: "<conversationId|user:ID|conversation:ID>",
},
directory: {
self: async () => null,
+5 -7
View File
@@ -150,14 +150,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
actions: zaloMessageActions,
messaging: {
normalizeTarget: normalizeZaloMessagingTarget,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
return /^\d{3,}$/.test(trimmed);
},
hint: "<chatId>",
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
return /^\d{3,}$/.test(trimmed);
},
targetHint: "<chatId>",
},
directory: {
self: async () => null,
+5 -7
View File
@@ -218,14 +218,12 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
if (!trimmed) return undefined;
return trimmed.replace(/^(zalouser|zlu):/i, "");
},
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
return /^\d{3,}$/.test(trimmed);
},
hint: "<threadId>",
looksLikeTargetId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
return /^\d{3,}$/.test(trimmed);
},
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(String.fromCharCode(111,107))"',
command: "node -e 'process.stdout.write(\"ok\")'",
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 "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);});"',
"node -e \"process.stdin.on('data', 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 Enter for pty sessions", async () => {
test("process submit sends CR for pty sessions", async () => {
const execTool = createExecTool();
const processTool = createProcessTool();
const result = await execTool.execute("toolcall", {
command:
'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);}});"',
"node -e \"process.stdin.on('data', d => { if (d.includes(13)) { process.stdout.write('submitted'); process.exit(0); } });\"",
pty: true,
background: true,
});
@@ -64,8 +64,7 @@ test("process submit sends Enter for pty sessions", async () => {
sessionId,
});
const deadline = Date.now() + (process.platform === "win32" ? 4000 : 2000);
while (Date.now() < deadline) {
for (let i = 0; i < 10; i += 1) {
await wait(50);
const poll = await processTool.execute("toolcall", { action: "poll", sessionId });
const details = poll.details as { status?: string; aggregated?: string };
+7 -2
View File
@@ -33,7 +33,9 @@ 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" })),
@@ -119,7 +121,10 @@ export function createProcessTool(
.sort((a, b) => b.startedAt - a.startedAt)
.map((s) => {
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
return `${s.sessionId} ${pad(
s.status,
9,
)} ${formatDuration(s.runtimeMs)} :: ${label}`;
});
return {
content: [
+1 -6
View File
@@ -1,11 +1,6 @@
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
@@ -1,31 +0,0 @@
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,12 +26,6 @@ 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;
@@ -131,9 +125,6 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
stabilityThreshold: debounceMs,
pollInterval: 100,
},
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
// This watcher only needs to react to skill changes.
ignored: DEFAULT_SKILLS_WATCH_IGNORED,
});
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
+3 -3
View File
@@ -150,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,42 +382,6 @@ 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,69 +41,4 @@ 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]");
});
});
+5 -16
View File
@@ -19,22 +19,11 @@ 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<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 suppressed = new Set(
Array.isArray(ctx.MediaUnderstanding)
? ctx.MediaUnderstanding.map((output) => output.attachmentIndex)
: [],
);
const pathsFromArray = Array.isArray(ctx.MediaPaths) ? ctx.MediaPaths : undefined;
const paths =
pathsFromArray && pathsFromArray.length > 0
-1
View File
@@ -68,7 +68,6 @@ 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,7 +24,6 @@ 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, "");
@@ -106,7 +105,6 @@ export async function buildStatusReply(params: {
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
isGroup: boolean;
defaultGroupActivation: () => "always" | "mention";
mediaDecisions?: MediaUnderstandingDecision[];
}): Promise<ReplyPayload | undefined> {
const {
cfg,
@@ -202,7 +200,6 @@ 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 hooks", () => {
describe("handleCommands internal hooks", () => {
it("triggers hooks for /new with arguments", async () => {
const cfg = {
commands: { text: true },
@@ -188,7 +188,6 @@ export async function applyInlineDirectiveOverrides(params: {
resolveDefaultThinkingLevel: async () => resolvedDefaultThinkLevel,
isGroup,
defaultGroupActivation: defaultActivation,
mediaDecisions: ctx.MediaUnderstandingDecisions,
});
}
typing.cleanup();
@@ -185,7 +185,6 @@ export async function handleInlineActions(params: {
resolveDefaultThinkingLevel,
isGroup,
defaultGroupActivation: defaultActivation,
mediaDecisions: ctx.MediaUnderstandingDecisions,
});
await sendInlineReply(inlineStatusReply);
directives = { ...directives, hasStatusDirective: false };
+3 -1
View File
@@ -297,7 +297,9 @@ 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",
GroupChannel: "#general",
GroupRoom: "#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 groupChannel = ctx.GroupChannel?.trim() ?? ctx.GroupSubject?.trim();
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
const requireMention = getChannelDock(channel)?.groups?.resolveRequireMention?.({
cfg,
groupId,
groupChannel,
groupRoom,
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 groupChannel = params.sessionCtx.GroupChannel?.trim() ?? subject;
const groupRoom = params.sessionCtx.GroupRoom?.trim() ?? subject;
const groupSpace = params.sessionCtx.GroupSpace?.trim();
const providerIdsLine = providerId
? getChannelDock(providerId)?.groups?.resolveGroupIntroHint?.({
cfg: params.cfg,
groupId,
groupChannel,
groupRoom,
groupSpace,
accountId: params.sessionCtx.AccountId,
})
+4 -1
View File
@@ -1,4 +1,7 @@
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";
+7 -10
View File
@@ -193,9 +193,11 @@ 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,
@@ -240,19 +242,14 @@ export async function initSessionState(params: {
const channel = groupResolution.channel;
const subject = ctx.GroupSubject?.trim();
const space = ctx.GroupSpace?.trim();
const explicitChannel = ctx.GroupChannel?.trim();
const explicitRoom = ctx.GroupRoom?.trim();
const normalizedChannel = normalizeChannelId(channel);
const isChannelProvider = Boolean(
const isRoomProvider = Boolean(
normalizedChannel &&
getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"),
);
const nextRoom =
explicitChannel ??
((groupResolution.chatType === "channel" || isChannelProvider) &&
subject &&
subject.startsWith("#")
? subject
: undefined);
explicitRoom ?? (isRoomProvider && subject && subject.startsWith("#") ? subject : undefined);
const nextSubject = nextRoom ? undefined : subject;
sessionEntry.chatType = groupResolution.chatType ?? "group";
sessionEntry.channel = channel;
-53
View File
@@ -90,59 +90,6 @@ 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,7 +24,6 @@ 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"]>>;
@@ -53,7 +52,6 @@ type StatusArgs = {
modelAuth?: string;
usageLine?: string;
queue?: QueueStatus;
mediaDecisions?: MediaUnderstandingDecision[];
includeTranscriptUsage?: boolean;
now?: number;
};
@@ -169,42 +167,6 @@ 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;
@@ -358,14 +320,12 @@ 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}`,
+2 -3
View File
@@ -63,8 +63,7 @@ export type MsgContext = {
/** Human label for envelope headers (conversation label, not sender). */
ConversationLabel?: string;
GroupSubject?: string;
/** Human label for channel-like group conversations (e.g. #general, #support). */
GroupChannel?: string;
GroupRoom?: string;
GroupSpace?: string;
GroupMembers?: string;
GroupSystemPrompt?: string;
@@ -97,7 +96,7 @@ export type MsgContext = {
*/
OriginatingTo?: string;
/**
* Messages from hooks to be included in the response.
* Messages from internal hooks to be included in the response.
* Used for hook confirmation messages like "Session context saved to memory".
*/
HookMessages?: string[];
-56
View File
@@ -90,23 +90,6 @@ 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 = "";
@@ -169,48 +152,9 @@ 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,23 +497,6 @@ 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,3 +70,4 @@ describe("resolveCommandAuthorizedFromAuthorizers", () => {
).toBe(true);
});
});
+1
View File
@@ -21,3 +21,4 @@ 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.GroupChannel?.trim() ||
ctx.GroupRoom?.trim() ||
ctx.GroupSubject?.trim() ||
ctx.GroupSpace?.trim() ||
ctx.From?.trim() ||
+24 -18
View File
@@ -1,17 +1,9 @@
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: {
@@ -24,11 +16,12 @@ describe("directory (config-backed)", () => {
},
} as any;
const peers = await listSlackDirectoryPeersFromConfig({
const peers = await slackPlugin.directory?.listPeers?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(peers?.map((e) => e.id).sort()).toEqual([
"user:u123",
@@ -37,16 +30,19 @@ describe("directory (config-backed)", () => {
"user:u999",
]);
const groups = await listSlackDirectoryGroupsFromConfig({
const groups = await slackPlugin.directory?.listGroups?.({
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: {
@@ -67,24 +63,28 @@ describe("directory (config-backed)", () => {
},
} as any;
const peers = await listDiscordDirectoryPeersFromConfig({
const peers = await discordPlugin.directory?.listPeers?.({
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 listDiscordDirectoryGroupsFromConfig({
const groups = await discordPlugin.directory?.listGroups?.({
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,24 +96,28 @@ describe("directory (config-backed)", () => {
},
} as any;
const peers = await listTelegramDirectoryPeersFromConfig({
const peers = await telegramPlugin.directory?.listPeers?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(peers?.map((e) => e.id).sort()).toEqual(["123", "456", "@alice", "@bob"]);
const groups = await listTelegramDirectoryGroupsFromConfig({
const groups = await telegramPlugin.directory?.listGroups?.({
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: {
@@ -123,19 +127,21 @@ describe("directory (config-backed)", () => {
},
} as any;
const peers = await listWhatsAppDirectoryPeersFromConfig({
const peers = await whatsappPlugin.directory?.listPeers?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]);
const groups = await listWhatsAppDirectoryGroupsFromConfig({
const groups = await whatsappPlugin.directory?.listGroups?.({
cfg,
accountId: "default",
query: null,
limit: null,
runtime,
});
expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]);
});
-214
View File
@@ -1,214 +0,0 @@
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);
}
+74 -11
View File
@@ -26,7 +26,10 @@ 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 {
@@ -35,10 +38,6 @@ 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");
@@ -153,15 +152,79 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
},
messaging: {
normalizeTarget: normalizeDiscordMessagingTarget,
targetResolver: {
looksLikeId: looksLikeDiscordTargetId,
hint: "<channelId|user:ID|channel:ID>",
},
looksLikeTargetId: looksLikeDiscordTargetId,
targetHint: "<channelId|user:ID|channel:ID>",
},
directory: {
self: async () => null,
listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params),
listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params),
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;
},
listGroupsLive: async ({ cfg, accountId, query, limit }) => {
const account = resolveDiscordAccount({ cfg, accountId });
const q = query?.trim().toLowerCase() || "";
+4 -6
View File
@@ -6,7 +6,7 @@ import { resolveSlackAccount } from "../../slack/accounts.js";
type GroupMentionParams = {
cfg: ClawdbotConfig;
groupId?: string | null;
groupChannel?: string | null;
groupRoom?: string | null;
groupSpace?: string | null;
accountId?: string | null;
};
@@ -133,14 +133,13 @@ export function resolveDiscordGroupRequireMention(params: GroupMentionParams): b
);
const channelEntries = guildEntry?.channels;
if (channelEntries && Object.keys(channelEntries).length > 0) {
const groupChannel = params.groupChannel;
const channelSlug = normalizeDiscordSlug(groupChannel);
const channelSlug = normalizeDiscordSlug(params.groupRoom);
const entry =
(params.groupId ? channelEntries[params.groupId] : undefined) ??
(channelSlug
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
: undefined) ??
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
(params.groupRoom ? channelEntries[normalizeDiscordSlug(params.groupRoom)] : undefined);
if (entry && typeof entry.requireMention === "boolean") {
return entry.requireMention;
}
@@ -160,8 +159,7 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo
const keys = Object.keys(channels);
if (keys.length === 0) return true;
const channelId = params.groupId?.trim();
const groupChannel = params.groupChannel;
const channelName = groupChannel?.replace(/^#/, "");
const channelName = params.groupRoom?.replace(/^#/, "");
const normalizedName = normalizeSlackSlug(channelName);
const candidates = [
channelId ?? "",
+7 -9
View File
@@ -107,16 +107,14 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
resolveRequireMention: resolveIMessageGroupRequireMention,
},
messaging: {
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>",
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);
},
targetHint: "<handle|chat_id:ID>",
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
-10
View File
@@ -74,14 +74,4 @@ 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";
+6 -5
View File
@@ -18,7 +18,10 @@ 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 {
@@ -115,10 +118,8 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
},
messaging: {
normalizeTarget: normalizeSignalMessagingTarget,
targetResolver: {
looksLikeId: looksLikeSignalTargetId,
hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
},
looksLikeTargetId: looksLikeSignalTargetId,
targetHint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
+53 -10
View File
@@ -28,10 +28,6 @@ import {
migrateBaseNameToDefaultAccount,
} from "./setup-helpers.js";
import type { ChannelMessageActionName, ChannelPlugin } from "./types.js";
import {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "./directory-config.js";
const meta = getChatChannelMeta("slack");
@@ -181,15 +177,62 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
},
messaging: {
normalizeTarget: normalizeSlackMessagingTarget,
targetResolver: {
looksLikeId: looksLikeSlackTargetId,
hint: "<channelId|user:ID|channel:ID>",
},
looksLikeTargetId: looksLikeSlackTargetId,
targetHint: "<channelId|user:ID|channel:ID>",
},
directory: {
self: async () => null,
listPeers: async (params) => listSlackDirectoryPeersFromConfig(params),
listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params),
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;
},
},
actions: {
listActions: ({ cfg }) => {
+45 -11
View File
@@ -26,7 +26,10 @@ 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 {
@@ -35,10 +38,6 @@ 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");
@@ -161,15 +160,50 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
},
messaging: {
normalizeTarget: normalizeTelegramMessagingTarget,
targetResolver: {
looksLikeId: looksLikeTelegramTargetId,
hint: "<chatId>",
},
looksLikeTargetId: looksLikeTelegramTargetId,
targetHint: "<chatId>",
},
directory: {
self: async () => null,
listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params),
listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params),
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;
},
},
actions: telegramMessageActions,
setup: {
+3 -6
View File
@@ -133,8 +133,7 @@ export type ChannelLogSink = {
export type ChannelGroupContext = {
cfg: ClawdbotConfig;
groupId?: string | null;
/** Human label for channel-like group conversations (e.g. #general). */
groupChannel?: string | null;
groupRoom?: string | null;
groupSpace?: string | null;
accountId?: string | null;
};
@@ -217,15 +216,13 @@ export type ChannelThreadingToolContext = {
export type ChannelMessagingAdapter = {
normalizeTarget?: (raw: string) => string | undefined;
targetResolver?: {
looksLikeId?: (raw: string, normalized?: string) => boolean;
hint?: string;
};
looksLikeTargetId?: (raw: string, normalized?: string) => boolean;
formatTargetDisplay?: (params: {
target: string;
display?: string;
kind?: ChannelDirectoryEntryKind;
}) => string;
targetHint?: string;
};
export type ChannelDirectoryEntryKind = "user" | "group" | "channel";
+31 -11
View File
@@ -26,7 +26,10 @@ 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,
@@ -36,10 +39,6 @@ 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");
@@ -223,10 +222,8 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
},
messaging: {
normalizeTarget: normalizeWhatsAppMessagingTarget,
targetResolver: {
looksLikeId: looksLikeWhatsAppTargetId,
hint: "<E.164|group JID>",
},
looksLikeTargetId: looksLikeWhatsAppTargetId,
targetHint: "<E.164|group JID>",
},
directory: {
self: async ({ cfg, accountId }) => {
@@ -241,8 +238,31 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
raw: { e164, jid },
};
},
listPeers: async (params) => listWhatsAppDirectoryPeersFromConfig(params),
listGroups: async (params) => listWhatsAppDirectoryGroupsFromConfig(params),
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;
},
},
actions: {
listActions: ({ cfg }) => {
+1 -3
View File
@@ -129,9 +129,7 @@ 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
@@ -1,54 +0,0 @@
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");
});
});
+8 -9
View File
@@ -12,11 +12,7 @@ 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";
@@ -129,7 +125,9 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
const notEligible = hooks.filter((h) => !h.eligible);
const lines: string[] = [];
lines.push(chalk.bold.cyan("Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`));
lines.push(
chalk.bold.cyan("Internal Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`),
);
lines.push("");
if (eligible.length > 0) {
@@ -275,7 +273,7 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
const notEligible = report.hooks.filter((h) => !h.eligible);
const lines: string[] = [];
lines.push(chalk.bold.cyan("Hooks Status"));
lines.push(chalk.bold.cyan("Internal Hooks Status"));
lines.push("");
lines.push(`Total hooks: ${report.hooks.length}`);
lines.push(chalk.green(`Ready: ${eligible.length}`));
@@ -375,7 +373,7 @@ export function registerHooksCli(program: Command): void {
hooks
.command("list")
.description("List all hooks")
.description("List all internal hooks")
.option("--eligible", "Show only eligible hooks", false)
.option("--json", "Output as JSON", false)
.option("-v, --verbose", "Show more details including missing requirements", false)
@@ -736,7 +734,8 @@ 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",
+1 -3
View File
@@ -51,9 +51,7 @@ 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
+4 -1
View File
@@ -243,7 +243,10 @@ 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 hooks when user selects them", async () => {
it("should enable internal 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 hooks?",
message: "Enable internal 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 hooks are
expect(noteCalls[0][0]).toContain("Hooks let you automate actions");
// First note should explain what internal hooks are
expect(noteCalls[0][0]).toContain("Internal hooks");
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(
[
"Hooks let you automate actions when agent commands are issued.",
"Internal 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/hooks",
"Learn more: https://docs.clawd.bot/internal-hooks",
].join("\n"),
"Hooks",
"Internal Hooks",
);
// Discover available hooks using the hook discovery system
@@ -35,7 +35,7 @@ export async function setupInternalHooks(
}
const toEnable = await prompter.multiselect({
message: "Enable hooks?",
message: "Enable internal hooks?",
options: [
{ value: "__skip__", label: "Skip for now" },
...recommendedHooks.map((hook) => ({
+30 -7
View File
@@ -16,8 +16,8 @@ describe("resolveChannelCapabilities", () => {
it("returns undefined for missing inputs", () => {
expect(resolveChannelCapabilities({})).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {} })).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {}, channel: "" })).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {} as ClawdbotConfig })).toBeUndefined();
expect(resolveChannelCapabilities({ cfg: {} as ClawdbotConfig, channel: "" })).toBeUndefined();
});
it("normalizes and prefers per-account capabilities", () => {
@@ -36,7 +36,7 @@ describe("resolveChannelCapabilities", () => {
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "telegram",
accountId: "default",
}),
@@ -57,7 +57,7 @@ describe("resolveChannelCapabilities", () => {
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "telegram",
accountId: "default",
}),
@@ -77,7 +77,7 @@ describe("resolveChannelCapabilities", () => {
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "slack",
accountId: "family",
}),
@@ -100,7 +100,7 @@ describe("resolveChannelCapabilities", () => {
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "msteams",
}),
).toEqual(["polls"]);
@@ -120,11 +120,34 @@ describe("resolveChannelCapabilities", () => {
// Should return undefined (not crash), allowing channel-specific handlers to process it.
expect(
resolveChannelCapabilities({
cfg,
cfg: cfg as ClawdbotConfig,
channel: "telegram",
}),
).toBeUndefined();
});
it("falls back to channel capabilities when account capabilities use object format", () => {
const cfg = {
channels: {
telegram: {
capabilities: ["inlineButtons"],
accounts: {
default: {
capabilities: { inlineButtons: "dm" },
},
},
},
},
} satisfies Partial<ClawdbotConfig>;
expect(
resolveChannelCapabilities({
cfg: cfg as ClawdbotConfig,
channel: "telegram",
accountId: "default",
}),
).toEqual(["inlineButtons"]);
});
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
+8 -13
View File
@@ -1,24 +1,19 @@
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";
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 {
function normalizeCapabilities(capabilities: string[] | undefined): string[] | undefined {
if (!capabilities) return undefined;
// Handle object-format capabilities (e.g., { inlineButtons: "dm" }) gracefully.
// Channel-specific handlers (like resolveTelegramInlineButtonsScope) process these separately.
if (!isStringArray(capabilities)) return undefined;
if (!Array.isArray(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?: CapabilitiesConfig }> } & {
capabilities?: CapabilitiesConfig;
cfg?: { accounts?: Record<string, { capabilities?: string[] }> } & {
capabilities?: string[];
};
accountId?: string | null;
}): string[] | undefined {
@@ -45,7 +40,7 @@ function resolveAccountCapabilities(params: {
}
export function resolveChannelCapabilities(params: {
cfg?: Partial<ClawdbotConfig>;
cfg?: ClawdbotConfig;
channel?: string | null;
accountId?: string | null;
}): string[] | undefined {
@@ -56,8 +51,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?: CapabilitiesConfig }>;
capabilities?: CapabilitiesConfig;
accounts?: Record<string, { capabilities?: string[] }>;
capabilities?: string[];
}
| undefined;
return resolveAccountCapabilities({
+1 -1
View File
@@ -90,7 +90,7 @@ export type HookInstallRecord = {
};
export type InternalHooksConfig = {
/** Enable hooks system */
/** Enable internal hooks system */
enabled?: boolean;
/** Legacy: List of internal hook handlers to register (still supported) */
handlers?: InternalHookHandlerConfig[];
+5 -1
View File
@@ -251,7 +251,11 @@ 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(),
})
+5 -1
View File
@@ -39,7 +39,11 @@ 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 groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel;
const groupRoom = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupRoom;
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,
GroupChannel: groupChannel,
GroupRoom: groupRoom,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
Provider: "discord" as const,
+3 -2
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 { toAgentRequestSessionKey } from "../routing/session-key.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
export function resolveSessionKeyForRun(runId: string) {
const cached = getAgentRunContext(runId)?.sessionKey;
@@ -12,7 +12,8 @@ export function resolveSessionKeyForRun(runId: string) {
const found = Object.entries(store).find(([, entry]) => entry?.sessionId === runId);
const storeKey = found?.[0];
if (storeKey) {
const sessionKey = toAgentRequestSessionKey(storeKey) ?? storeKey;
const parsed = parseAgentSessionKey(storeKey);
const sessionKey = parsed?.rest ?? 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 hooks: ${String(err)}`);
params.logHooks.error(`failed to load internal hooks: ${String(err)}`);
}
// Launch configured channels so gateway replies via the surface the message came from.
@@ -9,7 +9,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -27,16 +26,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-stale",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-stale",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -65,14 +70,20 @@ 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 writeSessionStore({
entries: {
"agent:main:subagent:abc": {
sessionId: "sess-sub",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:subagent:abc": {
sessionId: "sess-sub",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -100,17 +111,23 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-account",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "default",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main: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);
@@ -139,17 +156,23 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-explicit",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "legacy",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main: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);
@@ -178,17 +201,23 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-explicit-account",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "legacy",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main: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);
@@ -218,17 +247,23 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-implicit",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "kev",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main: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);
@@ -255,14 +290,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-images",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-images",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -302,14 +343,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-missing-provider",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-missing-provider",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -337,16 +384,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-whatsapp",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-whatsapp",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -376,16 +429,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "telegram",
lastTo: "123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "telegram",
lastTo: "123",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -414,16 +473,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-discord",
updatedAt: Date.now(),
lastChannel: "discord",
lastTo: "channel:discord-123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main: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);
@@ -452,16 +517,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-slack",
updatedAt: Date.now(),
lastChannel: "slack",
lastTo: "channel:slack-123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main: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);
@@ -490,16 +561,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-signal",
updatedAt: Date.now(),
lastChannel: "signal",
lastTo: "+15551234567",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-signal",
updatedAt: Date.now(),
lastChannel: "signal",
lastTo: "+15551234567",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -18,7 +18,6 @@ import {
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -81,16 +80,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-teams",
updatedAt: Date.now(),
lastChannel: "msteams",
lastTo: "conversation:teams-123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main: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);
@@ -128,16 +133,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-alias",
updatedAt: Date.now(),
lastChannel: "imessage",
lastTo: "chat_id:123",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main: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);
@@ -195,16 +206,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-webchat",
updatedAt: Date.now(),
lastChannel: "webchat",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main-webchat",
updatedAt: Date.now(),
lastChannel: "webchat",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -233,16 +250,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main-webchat-internal",
updatedAt: Date.now(),
lastChannel: "webchat",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main: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);
@@ -385,14 +408,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
@@ -11,7 +11,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -67,15 +66,21 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
verboseLevel: "off",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
verboseLevel: "off",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
@@ -11,7 +11,6 @@ import {
sessionStoreSaveDelayMs,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -29,14 +28,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -73,16 +78,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -94,12 +105,11 @@ describe("gateway server chat", () => {
});
expect(res.ok).toBe(true);
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");
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");
ws.close();
await server.close();
@@ -108,14 +118,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
let inFlight: Promise<unknown> | undefined;
@@ -194,14 +210,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
sessionStoreSaveDelayMs.value = 120;
@@ -266,11 +288,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 writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
},
});
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify({ main: { sessionId: "sess-main", updatedAt: Date.now() } }, null, 2),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -11,7 +11,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -97,7 +96,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 writeSessionStore({ entries: {} });
await fs.writeFile(testState.sessionStorePath, JSON.stringify({}, null, 2), "utf-8");
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -117,14 +116,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -184,14 +189,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -248,14 +259,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -12,7 +12,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -107,16 +106,22 @@ describe("gateway server chat", () => {
},
};
await writeSessionStore({
entries: {
"discord:group:dev": {
sessionId: "sess-discord",
updatedAt: Date.now(),
chatType: "group",
channel: "discord",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"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);
@@ -143,14 +148,20 @@ describe("gateway server chat", () => {
},
};
await writeSessionStore({
entries: {
"cron:job-1": {
sessionId: "sess-cron",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"cron:job-1": {
sessionId: "sess-cron",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -225,14 +236,20 @@ describe("gateway server chat", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const lines: string[] = [];
for (let i = 0; i < 300; i += 1) {
@@ -332,15 +349,21 @@ describe("gateway server chat", () => {
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
sessionFile: forkedPath,
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
sessionFile: forkedPath,
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -374,14 +397,20 @@ describe("gateway server chat", () => {
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -418,14 +447,20 @@ describe("gateway server chat", () => {
];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
JSON.stringify({
@@ -16,7 +16,6 @@ import {
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
const _decodeWsData = (data: unknown): string => {
@@ -218,14 +217,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
@@ -269,14 +274,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -320,14 +331,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -391,14 +408,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -14,7 +14,6 @@ import {
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
const decodeWsData = (data: unknown): string => {
@@ -43,16 +42,22 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
},
});
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
@@ -87,14 +92,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
@@ -172,14 +183,20 @@ 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 writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
sessionStoreSaveDelayMs.value = 120;
@@ -10,7 +10,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
@@ -66,35 +65,41 @@ describe("gateway server sessions", () => {
"utf-8",
);
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",
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,
},
},
"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,
},
},
});
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
const hello = await connectOk(ws);
@@ -350,15 +355,21 @@ describe("gateway server sessions", () => {
"utf-8",
);
await writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
"discord:group:dev": {
sessionId: "sess-active",
updatedAt: Date.now(),
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(),
},
},
},
});
null,
2,
),
"utf-8",
);
embeddedRunMock.activeIds.add("sess-active");
embeddedRunMock.waitResults.set("sess-active", true);
@@ -8,7 +8,6 @@ import {
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
@@ -26,30 +25,38 @@ describe("gateway server sessions", () => {
const workDir = path.join(dir, "work");
await fs.mkdir(homeDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await writeSessionStore({
storePath: path.join(homeDir, "sessions.json"),
agentId: "home",
entries: {
main: {
sessionId: "sess-home-main",
updatedAt: Date.now(),
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,
},
},
"discord:group:dev": {
sessionId: "sess-home-group",
updatedAt: Date.now() - 1000,
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(workDir, "sessions.json"),
JSON.stringify(
{
"agent:work:main": {
sessionId: "sess-work-main",
updatedAt: Date.now(),
},
},
},
});
await writeSessionStore({
storePath: path.join(workDir, "sessions.json"),
agentId: "work",
entries: {
main: {
sessionId: "sess-work-main",
updatedAt: Date.now(),
},
},
});
null,
2,
),
"utf-8",
);
const { ws } = await startServerWithClient();
await connectOk(ws);
@@ -85,17 +92,20 @@ describe("gateway server sessions", () => {
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
testState.sessionConfig = { mainKey: "work" };
await writeSessionStore({
await fs.writeFile(
storePath,
agentId: "ops",
mainKey: "work",
entries: {
main: {
sessionId: "sess-ops-main",
updatedAt: Date.now(),
JSON.stringify(
{
"agent:ops:work": {
sessionId: "sess-ops-main",
updatedAt: Date.now(),
},
},
},
});
null,
2,
),
"utf-8",
);
const { ws } = await startServerWithClient();
await connectOk(ws);
+1 -28
View File
@@ -6,12 +6,11 @@ import path from "node:path";
import { afterEach, beforeEach, expect } from "vitest";
import { WebSocket } from "ws";
import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js";
import { resolveMainSessionKeyFromConfig } 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";
@@ -32,32 +31,6 @@ 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 Hooks
# Bundled Internal Hooks
This directory contains hooks that ship with Clawdbot. These hooks are automatically discovered and can be enabled/disabled via CLI or configuration.
This directory contains internal 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 { HookHandler } from "../../src/hooks/hooks.js";
import type { InternalHookHandler } from "../../src/hooks/internal-hooks.js";
const myHandler: HookHandler = async (event) => {
const myHandler: InternalHookHandler = 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/hooks
Full documentation: https://docs.clawd.bot/internal-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/hooks#command-logger
homepage: https://docs.clawd.bot/internal-hooks#command-logger
metadata:
{
"clawdbot":
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* Example hook handler: Log all commands to a file
* Example internal 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 { HookHandler } from "../../hooks.js";
import type { InternalHookHandler } from "../../internal-hooks.js";
/**
* Log all command events to a file
*/
const logCommand: HookHandler = async (event) => {
const logCommand: InternalHookHandler = 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/hooks#session-memory
homepage: https://docs.clawd.bot/internal-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 { HookHandler } from "../../hooks.js";
import type { InternalHookHandler } from "../../internal-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: HookHandler = async (event) => {
const saveSessionToMemory: InternalHookHandler = async (event) => {
// Only trigger on 'new' command
if (event.type !== "command" || event.action !== "new") {
return;
-115
View File
@@ -1,115 +0,0 @@
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
@@ -1,14 +0,0 @@
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";
+5 -52
View File
@@ -3,7 +3,6 @@ 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[] = [];
@@ -62,7 +61,7 @@ describe("installHooksFromArchive", () => {
"---",
"name: zip-hook",
"description: Zip hook",
'metadata: {"clawdbot":{"events":["command:new"]}}',
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
"---",
"",
"# Zip Hook",
@@ -82,55 +81,9 @@ 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);
});
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"));
expect(
fs.existsSync(path.join(result.targetDir, "hooks", "zip-hook", "HOOK.md")),
).toBe(true);
});
});
@@ -146,7 +99,7 @@ describe("installHooksFromPath", () => {
"---",
"name: my-hook",
"description: My hook",
'metadata: {"clawdbot":{"events":["command:new"]}}',
"metadata: {\"clawdbot\":{\"events\":[\"command:new\"]}}",
"---",
"",
"# My Hook",
+2 -6
View File
@@ -134,9 +134,7 @@ 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);
@@ -234,9 +232,7 @@ 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("hooks", () => {
describe("internal-hooks", () => {
beforeEach(() => {
clearInternalHooks();
});
@@ -131,7 +131,7 @@ describe("hooks", () => {
expect(errorHandler).toHaveBeenCalled();
expect(successHandler).toHaveBeenCalled();
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Hook error"),
expect.stringContaining("Internal hook error"),
expect.stringContaining("Handler failed"),
);
+5 -5
View File
@@ -1,7 +1,7 @@
/**
* Hook system for clawdbot agent events
* Internal hook system for clawdbot agent events
*
* Provides an extensible event-driven hook system for agent events
* Provides an extensible event-driven hook system for internal agent events
* like command processing, session lifecycle, etc.
*/
@@ -91,7 +91,7 @@ export function getRegisteredEventKeys(): string[] {
}
/**
* Trigger a hook event
* Trigger an internal 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(
`Hook error [${event.type}:${event.action}]:`,
`Internal 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 a hook event with common fields filled in
* Create an internal 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 hooks are not enabled", async () => {
it("should return 0 when internal 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 hook handler"),
expect.stringContaining("Failed to load internal hook handler"),
expect.any(String),
);
+15 -11
View File
@@ -1,5 +1,5 @@
/**
* Dynamic loader for hook handlers
* Dynamic loader for internal 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 hook handlers
* Load and register all internal 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} hook handlers`);
* console.log(`Loaded ${count} internal hook handlers`);
* ```
*/
export async function loadInternalHooks(
cfg: ClawdbotConfig,
workspaceDir: string,
): Promise<number> {
// Check if hooks are enabled
// Check if internal hooks are enabled
if (!cfg.hooks?.internal?.enabled) {
return 0;
}
@@ -71,7 +71,7 @@ export async function loadInternalHooks(
if (typeof handler !== "function") {
console.error(
`Hook error: Handler '${exportName}' from ${entry.hook.name} is not a function`,
`Internal hook error: Handler '${exportName}' from ${entry.hook.name} is not a function`,
);
continue;
}
@@ -79,7 +79,9 @@ export async function loadInternalHooks(
// Register for all events listed in metadata
const events = entry.clawdbot?.events ?? [];
if (events.length === 0) {
console.warn(`Hook warning: Hook '${entry.hook.name}' has no events defined in metadata`);
console.warn(
`Internal hook warning: Hook '${entry.hook.name}' has no events defined in metadata`,
);
continue;
}
@@ -88,12 +90,12 @@ export async function loadInternalHooks(
}
console.log(
`Registered hook: ${entry.hook.name} -> ${events.join(", ")}${exportName !== "default" ? ` (export: ${exportName})` : ""}`,
`Registered internal hook: ${entry.hook.name} -> ${events.join(", ")}${exportName !== "default" ? ` (export: ${exportName})` : ""}`,
);
loadedCount++;
} catch (err) {
console.error(
`Failed to load hook ${entry.hook.name}:`,
`Failed to load internal hook ${entry.hook.name}:`,
err instanceof Error ? err.message : String(err),
);
}
@@ -124,19 +126,21 @@ export async function loadInternalHooks(
const handler = mod[exportName];
if (typeof handler !== "function") {
console.error(`Hook error: Handler '${exportName}' from ${modulePath} is not a function`);
console.error(
`Internal hook error: Handler '${exportName}' from ${modulePath} is not a function`,
);
continue;
}
// Register the handler
registerInternalHook(handlerConfig.event, handler as InternalHookHandler);
console.log(
`Registered hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== "default" ? `#${exportName}` : ""}`,
`Registered internal hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== "default" ? `#${exportName}` : ""}`,
);
loadedCount++;
} catch (err) {
console.error(
`Failed to load hook handler from ${handlerConfig.module}:`,
`Failed to load internal hook handler from ${handlerConfig.module}:`,
err instanceof Error ? err.message : String(err),
);
}
+1 -5
View File
@@ -48,11 +48,7 @@ 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
@@ -1,68 +0,0 @@
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");
});
});
+4 -1
View File
@@ -61,7 +61,10 @@ 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);
+1 -5
View File
@@ -9,11 +9,7 @@ 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${formatTargetHint(hint)}`;
return `Delivering to ${provider} requires target${formatHint(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.${formatTargetHint(hint, true)}`;
return `Ambiguous target "${raw}" for ${provider}. Provide a unique name or an explicit id.${formatHint(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}.${formatTargetHint(hint, true)}`;
return `Unknown target "${raw}" for ${provider}.${formatHint(hint, true)}`;
}
export function unknownTargetError(provider: string, raw: string, hint?: string): Error {
return new Error(unknownTargetMessage(provider, raw, hint));
}
function formatTargetHint(hint?: string, withLabel = false): string {
function formatHint(hint?: string, withLabel = false): string {
if (!hint) return "";
return withLabel ? ` Hint: ${hint}` : ` ${hint}`;
}
+2 -3
View File
@@ -16,9 +16,8 @@ export function normalizeTargetForProvider(provider: string, raw?: string): stri
export function buildTargetResolverSignature(channel: ChannelId): string {
const plugin = getChannelPlugin(channel);
const resolver = plugin?.messaging?.targetResolver;
const hint = resolver?.hint ?? "";
const looksLike = resolver?.looksLikeId;
const hint = plugin?.messaging?.targetHint ?? "";
const looksLike = plugin?.messaging?.looksLikeTargetId;
const source = looksLike ? looksLike.toString() : "";
return hashSignature(`${hint}|${source}`);
}
+20 -14
View File
@@ -164,6 +164,24 @@ 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;
@@ -267,22 +285,10 @@ export async function resolveMessagingTarget(params: {
}
const plugin = getChannelPlugin(params.channel);
const providerLabel = plugin?.meta?.label ?? params.channel;
const hint = plugin?.messaging?.targetResolver?.hint;
const hint = plugin?.messaging?.targetHint;
const kind = detectTargetKind(raw, params.preferredKind);
const normalized = normalizeTargetForProvider(params.channel, raw) ?? raw;
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()) {
if (looksLikeTargetId({ channel: params.channel, raw, normalized })) {
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?.targetResolver?.hint;
const hint = plugin.messaging?.targetHint;
return {
ok: false,
error: missingTargetError(plugin.meta.label ?? params.channel, hint),
+3 -61
View File
@@ -121,7 +121,9 @@ 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");
@@ -422,64 +424,4 @@ 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");
});
});
+521 -17
View File
@@ -1,26 +1,52 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { MsgContext } from "../auto-reply/templating.js";
import { applyTemplate } from "../auto-reply/templating.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { runExec } from "../process/exec.js";
import type {
MediaUnderstandingConfig,
MediaUnderstandingModelConfig,
} from "../config/types.tools.js";
import { MediaAttachmentCache, normalizeAttachments, selectAttachments } from "./attachments.js";
import {
CLI_OUTPUT_MAX_BUFFER,
DEFAULT_AUDIO_MODELS,
DEFAULT_TIMEOUT_SECONDS,
} from "./defaults.js";
import { isMediaUnderstandingSkipError, MediaUnderstandingSkipError } from "./errors.js";
import {
extractMediaUserText,
formatAudioTranscripts,
formatMediaUnderstandingBody,
} from "./format.js";
import {
buildMediaUnderstandingRegistry,
getMediaUnderstandingProvider,
normalizeMediaProviderId,
} from "./providers/index.js";
import { describeImageWithModel } from "./providers/image.js";
import {
resolveCapabilityConfig,
inferProviderCapabilities,
resolveConcurrency,
resolveMaxBytes,
resolveMaxChars,
resolveModelEntries,
resolvePrompt,
resolveScopeDecision,
resolveTimeoutMs,
} from "./resolve.js";
import type {
MediaUnderstandingCapability,
MediaUnderstandingDecision,
MediaUnderstandingModelDecision,
MediaUnderstandingOutput,
MediaUnderstandingProvider,
} from "./types.js";
import { runWithConcurrency } from "./concurrency.js";
import { resolveConcurrency } from "./resolve.js";
import {
type ActiveMediaModel,
buildProviderRegistry,
createMediaAttachmentCache,
normalizeMediaAttachments,
runCapability,
} from "./runner.js";
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js";
export type ApplyMediaUnderstandingResult = {
outputs: MediaUnderstandingOutput[];
@@ -32,6 +58,476 @@ export type ApplyMediaUnderstandingResult = {
const CAPABILITY_ORDER: MediaUnderstandingCapability[] = ["image", "audio", "video"];
type ActiveMediaModel = {
provider: string;
model?: string;
};
function trimOutput(text: string, maxChars?: number): string {
const trimmed = text.trim();
if (!maxChars || trimmed.length <= maxChars) return trimmed;
return trimmed.slice(0, maxChars).trim();
}
function resolveEntriesWithActiveFallback(params: {
cfg: ClawdbotConfig;
capability: MediaUnderstandingCapability;
config?: MediaUnderstandingConfig;
activeModel?: ActiveMediaModel;
}): MediaUnderstandingModelConfig[] {
const entries = resolveModelEntries({
cfg: params.cfg,
capability: params.capability,
config: params.config,
});
if (entries.length > 0) return entries;
if (params.config?.enabled !== true) return entries;
const activeProvider = params.activeModel?.provider?.trim();
if (!activeProvider) return entries;
const capabilities = inferProviderCapabilities(activeProvider);
if (!capabilities || !capabilities.includes(params.capability)) return entries;
return [
{
type: "provider",
provider: activeProvider,
model: params.activeModel?.model,
},
];
}
function buildModelDecision(params: {
entry: MediaUnderstandingModelConfig;
entryType: "provider" | "cli";
outcome: MediaUnderstandingModelDecision["outcome"];
reason?: string;
}): MediaUnderstandingModelDecision {
if (params.entryType === "cli") {
const command = params.entry.command?.trim();
return {
type: "cli",
provider: command ?? "cli",
model: params.entry.model ?? command,
outcome: params.outcome,
reason: params.reason,
};
}
const providerIdRaw = params.entry.provider?.trim();
const providerId = providerIdRaw ? normalizeMediaProviderId(providerIdRaw) : undefined;
return {
type: "provider",
provider: providerId ?? providerIdRaw,
model: params.entry.model,
outcome: params.outcome,
reason: params.reason,
};
}
async function runProviderEntry(params: {
capability: MediaUnderstandingCapability;
entry: MediaUnderstandingModelConfig;
cfg: ClawdbotConfig;
ctx: MsgContext;
attachmentIndex: number;
cache: MediaAttachmentCache;
agentDir?: string;
providerRegistry: Map<string, MediaUnderstandingProvider>;
config?: MediaUnderstandingConfig;
}): Promise<MediaUnderstandingOutput | null> {
const { entry, capability, cfg } = params;
const providerIdRaw = entry.provider?.trim();
if (!providerIdRaw) {
throw new Error(`Provider entry missing provider for ${capability}`);
}
const providerId = normalizeMediaProviderId(providerIdRaw);
const maxBytes = resolveMaxBytes({ capability, entry, cfg, config: params.config });
const maxChars = resolveMaxChars({ capability, entry, cfg, config: params.config });
const timeoutMs = resolveTimeoutMs(
entry.timeoutSeconds ??
params.config?.timeoutSeconds ??
cfg.tools?.media?.[capability]?.timeoutSeconds,
DEFAULT_TIMEOUT_SECONDS[capability],
);
const prompt = resolvePrompt(
capability,
entry.prompt ?? params.config?.prompt ?? cfg.tools?.media?.[capability]?.prompt,
maxChars,
);
if (capability === "image") {
if (!params.agentDir) {
throw new Error("Image understanding requires agentDir");
}
const modelId = entry.model?.trim();
if (!modelId) {
throw new Error("Image understanding requires model id");
}
const media = await params.cache.getBuffer({
attachmentIndex: params.attachmentIndex,
maxBytes,
timeoutMs,
});
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
const result = provider?.describeImage
? await provider.describeImage({
buffer: media.buffer,
fileName: media.fileName,
mime: media.mime,
model: modelId,
provider: providerId,
prompt,
timeoutMs,
profile: entry.profile,
preferredProfile: entry.preferredProfile,
agentDir: params.agentDir,
cfg: params.cfg,
})
: await describeImageWithModel({
buffer: media.buffer,
fileName: media.fileName,
mime: media.mime,
model: modelId,
provider: providerId,
prompt,
timeoutMs,
profile: entry.profile,
preferredProfile: entry.preferredProfile,
agentDir: params.agentDir,
cfg: params.cfg,
});
return {
kind: "image.description",
attachmentIndex: params.attachmentIndex,
text: trimOutput(result.text, maxChars),
provider: providerId,
model: result.model ?? modelId,
};
}
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
if (!provider) {
throw new Error(`Media provider not available: ${providerId}`);
}
if (capability === "audio") {
if (!provider.transcribeAudio) {
throw new Error(`Audio transcription provider "${providerId}" not available.`);
}
const media = await params.cache.getBuffer({
attachmentIndex: params.attachmentIndex,
maxBytes,
timeoutMs,
});
const key = await resolveApiKeyForProvider({
provider: providerId,
cfg,
profileId: entry.profile,
preferredProfile: entry.preferredProfile,
agentDir: params.agentDir,
});
const providerConfig = cfg.models?.providers?.[providerId];
const model = entry.model?.trim() || DEFAULT_AUDIO_MODELS[providerId] || entry.model;
const result = await provider.transcribeAudio({
buffer: media.buffer,
fileName: media.fileName,
mime: media.mime,
apiKey: key.apiKey,
baseUrl: providerConfig?.baseUrl,
headers: providerConfig?.headers,
model,
language: entry.language ?? params.config?.language ?? cfg.tools?.media?.audio?.language,
prompt,
timeoutMs,
});
return {
kind: "audio.transcription",
attachmentIndex: params.attachmentIndex,
text: trimOutput(result.text, maxChars),
provider: providerId,
model: result.model ?? model,
};
}
if (!provider.describeVideo) {
throw new Error(`Video understanding provider "${providerId}" not available.`);
}
const media = await params.cache.getBuffer({
attachmentIndex: params.attachmentIndex,
maxBytes,
timeoutMs,
});
const estimatedBase64Bytes = estimateBase64Size(media.size);
const maxBase64Bytes = resolveVideoMaxBase64Bytes(maxBytes);
if (estimatedBase64Bytes > maxBase64Bytes) {
throw new MediaUnderstandingSkipError(
"maxBytes",
`Video attachment ${params.attachmentIndex + 1} base64 payload ${estimatedBase64Bytes} exceeds ${maxBase64Bytes}`,
);
}
const key = await resolveApiKeyForProvider({
provider: providerId,
cfg,
profileId: entry.profile,
preferredProfile: entry.preferredProfile,
agentDir: params.agentDir,
});
const providerConfig = cfg.models?.providers?.[providerId];
const result = await provider.describeVideo({
buffer: media.buffer,
fileName: media.fileName,
mime: media.mime,
apiKey: key.apiKey,
baseUrl: providerConfig?.baseUrl,
headers: providerConfig?.headers,
model: entry.model,
prompt,
timeoutMs,
});
return {
kind: "video.description",
attachmentIndex: params.attachmentIndex,
text: trimOutput(result.text, maxChars),
provider: providerId,
model: result.model ?? entry.model,
};
}
async function runCliEntry(params: {
capability: MediaUnderstandingCapability;
entry: MediaUnderstandingModelConfig;
cfg: ClawdbotConfig;
ctx: MsgContext;
attachmentIndex: number;
cache: MediaAttachmentCache;
config?: MediaUnderstandingConfig;
}): Promise<MediaUnderstandingOutput | null> {
const { entry, capability, cfg, ctx } = params;
const command = entry.command?.trim();
const args = entry.args ?? [];
if (!command) {
throw new Error(`CLI entry missing command for ${capability}`);
}
const maxBytes = resolveMaxBytes({ capability, entry, cfg, config: params.config });
const maxChars = resolveMaxChars({ capability, entry, cfg, config: params.config });
const timeoutMs = resolveTimeoutMs(
entry.timeoutSeconds ??
params.config?.timeoutSeconds ??
cfg.tools?.media?.[capability]?.timeoutSeconds,
DEFAULT_TIMEOUT_SECONDS[capability],
);
const prompt = resolvePrompt(
capability,
entry.prompt ?? params.config?.prompt ?? cfg.tools?.media?.[capability]?.prompt,
maxChars,
);
const pathResult = await params.cache.getPath({
attachmentIndex: params.attachmentIndex,
maxBytes,
timeoutMs,
});
const templCtx: MsgContext = {
...ctx,
MediaPath: pathResult.path,
Prompt: prompt,
MaxChars: maxChars,
};
const argv = [command, ...args].map((part, index) =>
index === 0 ? part : applyTemplate(part, templCtx),
);
if (shouldLogVerbose()) {
logVerbose(`Media understanding via CLI: ${argv.join(" ")}`);
}
const { stdout } = await runExec(argv[0], argv.slice(1), {
timeoutMs,
maxBuffer: CLI_OUTPUT_MAX_BUFFER,
});
const text = trimOutput(stdout, maxChars);
if (!text) return null;
return {
kind: capability === "audio" ? "audio.transcription" : `${capability}.description`,
attachmentIndex: params.attachmentIndex,
text,
provider: "cli",
model: command,
};
}
async function runAttachmentEntries(params: {
capability: MediaUnderstandingCapability;
cfg: ClawdbotConfig;
ctx: MsgContext;
attachmentIndex: number;
agentDir?: string;
providerRegistry: Map<string, MediaUnderstandingProvider>;
cache: MediaAttachmentCache;
entries: MediaUnderstandingModelConfig[];
config?: MediaUnderstandingConfig;
}): Promise<{ output: MediaUnderstandingOutput | null; attempts: MediaUnderstandingModelDecision[] }> {
const { entries, capability } = params;
const attempts: MediaUnderstandingModelDecision[] = [];
for (const entry of entries) {
try {
const entryType = entry.type ?? (entry.command ? "cli" : "provider");
const result =
entryType === "cli"
? await runCliEntry({
capability,
entry,
cfg: params.cfg,
ctx: params.ctx,
attachmentIndex: params.attachmentIndex,
cache: params.cache,
config: params.config,
})
: await runProviderEntry({
capability,
entry,
cfg: params.cfg,
ctx: params.ctx,
attachmentIndex: params.attachmentIndex,
cache: params.cache,
agentDir: params.agentDir,
providerRegistry: params.providerRegistry,
config: params.config,
});
if (result) {
const decision = buildModelDecision({ entry, entryType, outcome: "success" });
if (result.provider) decision.provider = result.provider;
if (result.model) decision.model = result.model;
attempts.push(decision);
return { output: result, attempts };
}
attempts.push(
buildModelDecision({ entry, entryType, outcome: "skipped", reason: "empty output" }),
);
} catch (err) {
if (isMediaUnderstandingSkipError(err)) {
attempts.push(
buildModelDecision({
entry,
entryType: entry.type ?? (entry.command ? "cli" : "provider"),
outcome: "skipped",
reason: `${err.reason}: ${err.message}`,
}),
);
if (shouldLogVerbose()) {
logVerbose(`Skipping ${capability} model due to ${err.reason}: ${err.message}`);
}
continue;
}
attempts.push(
buildModelDecision({
entry,
entryType: entry.type ?? (entry.command ? "cli" : "provider"),
outcome: "failed",
reason: String(err),
}),
);
if (shouldLogVerbose()) {
logVerbose(`${capability} understanding failed: ${String(err)}`);
}
}
}
return { output: null, attempts };
}
async function runCapability(params: {
capability: MediaUnderstandingCapability;
cfg: ClawdbotConfig;
ctx: MsgContext;
attachments: MediaAttachmentCache;
media: ReturnType<typeof normalizeAttachments>;
agentDir?: string;
providerRegistry: Map<string, MediaUnderstandingProvider>;
config?: MediaUnderstandingConfig;
activeModel?: ActiveMediaModel;
}): Promise<{ outputs: MediaUnderstandingOutput[]; decision: MediaUnderstandingDecision }> {
const { capability, cfg, ctx } = params;
const config = params.config ?? resolveCapabilityConfig(cfg, capability);
if (config?.enabled === false) {
return {
outputs: [],
decision: { capability, outcome: "disabled", attachments: [] },
};
}
const attachmentPolicy = config?.attachments;
const selected = selectAttachments({
capability,
attachments: params.media,
policy: attachmentPolicy,
});
if (selected.length === 0) {
return {
outputs: [],
decision: { capability, outcome: "no-attachment", attachments: [] },
};
}
const scopeDecision = resolveScopeDecision({ scope: config?.scope, ctx });
if (scopeDecision === "deny") {
if (shouldLogVerbose()) {
logVerbose(`${capability} understanding disabled by scope policy.`);
}
return {
outputs: [],
decision: {
capability,
outcome: "scope-deny",
attachments: selected.map((item) => ({ attachmentIndex: item.index, attempts: [] })),
},
};
}
const entries = resolveEntriesWithActiveFallback({
cfg,
capability,
config,
activeModel: params.activeModel,
});
if (entries.length === 0) {
return {
outputs: [],
decision: {
capability,
outcome: "skipped",
attachments: selected.map((item) => ({ attachmentIndex: item.index, attempts: [] })),
},
};
}
const outputs: MediaUnderstandingOutput[] = [];
const attachmentDecisions: MediaUnderstandingDecision["attachments"] = [];
for (const attachment of selected) {
const { output, attempts } = await runAttachmentEntries({
capability,
cfg,
ctx,
attachmentIndex: attachment.index,
agentDir: params.agentDir,
providerRegistry: params.providerRegistry,
cache: params.attachments,
entries,
config,
});
if (output) outputs.push(output);
attachmentDecisions.push({
attachmentIndex: attachment.index,
attempts,
chosen: attempts.find((attempt) => attempt.outcome === "success"),
});
}
return {
outputs,
decision: {
capability,
outcome: outputs.length > 0 ? "success" : "skipped",
attachments: attachmentDecisions,
},
};
}
export async function applyMediaUnderstanding(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
@@ -46,13 +542,13 @@ export async function applyMediaUnderstanding(params: {
.map((value) => extractMediaUserText(value))
.find((value) => value && value.trim()) ?? undefined;
const attachments = normalizeMediaAttachments(ctx);
const providerRegistry = buildProviderRegistry(params.providers);
const cache = createMediaAttachmentCache(attachments);
const attachments = normalizeAttachments(ctx);
const providerRegistry = buildMediaUnderstandingRegistry(params.providers);
const cache = new MediaAttachmentCache(attachments);
try {
const tasks = CAPABILITY_ORDER.map((capability) => async () => {
const config = cfg.tools?.media?.[capability];
const config = resolveCapabilityConfig(cfg, capability);
return await runCapability({
capability,
cfg,
@@ -69,16 +565,24 @@ export async function applyMediaUnderstanding(params: {
const results = await runWithConcurrency(tasks, resolveConcurrency(cfg));
const outputs: MediaUnderstandingOutput[] = [];
const decisions: MediaUnderstandingDecision[] = [];
for (const entry of results) {
for (const [index] of CAPABILITY_ORDER.entries()) {
const entry = results[index];
if (!entry) continue;
for (const output of entry.outputs) {
outputs.push(output);
if (Array.isArray(entry.outputs)) {
for (const output of entry.outputs) {
outputs.push(output);
}
}
if (entry.decision) {
decisions.push(entry.decision);
}
decisions.push(entry.decision);
}
if (decisions.length > 0) {
ctx.MediaUnderstandingDecisions = [...(ctx.MediaUnderstandingDecisions ?? []), ...decisions];
ctx.MediaUnderstandingDecisions = [
...(ctx.MediaUnderstandingDecisions ?? []),
...decisions,
];
}
if (outputs.length > 0) {
+11 -20
View File
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
import type { MsgContext } from "../auto-reply/templating.js";
import type { MediaUnderstandingAttachmentsConfig } from "../config/types.tools.js";
import { fetchRemoteMedia, MediaFetchError } from "../media/fetch.js";
import { detectMime, getFileExtension, isAudioFileName, kindFromMime } from "../media/mime.js";
import { detectMime, getFileExtension, isAudioFileName } from "../media/mime.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { fetchWithTimeout } from "./providers/shared.js";
import type { MediaAttachment, MediaUnderstandingCapability } from "./types.js";
@@ -100,32 +100,23 @@ export function normalizeAttachments(ctx: MsgContext): MediaAttachment[] {
];
}
export function resolveAttachmentKind(
attachment: MediaAttachment,
): "image" | "audio" | "video" | "document" | "unknown" {
const kind = kindFromMime(attachment.mime);
if (kind === "image" || kind === "audio" || kind === "video") return kind;
const ext = getFileExtension(attachment.path ?? attachment.url);
if (!ext) return "unknown";
if ([".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v"].includes(ext)) return "video";
if (isAudioFileName(attachment.path ?? attachment.url)) return "audio";
if ([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".tif"].includes(ext)) {
return "image";
}
return "unknown";
}
export function isVideoAttachment(attachment: MediaAttachment): boolean {
return resolveAttachmentKind(attachment) === "video";
if (attachment.mime?.startsWith("video/")) return true;
const ext = getFileExtension(attachment.path ?? attachment.url);
if (!ext) return false;
return [".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v"].includes(ext);
}
export function isAudioAttachment(attachment: MediaAttachment): boolean {
return resolveAttachmentKind(attachment) === "audio";
if (attachment.mime?.startsWith("audio/")) return true;
return isAudioFileName(attachment.path ?? attachment.url);
}
export function isImageAttachment(attachment: MediaAttachment): boolean {
return resolveAttachmentKind(attachment) === "image";
if (attachment.mime?.startsWith("image/")) return true;
const ext = getFileExtension(attachment.path ?? attachment.url);
if (!ext) return false;
return [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".tif"].includes(ext);
}
function isAbortError(err: unknown): boolean {
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more