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