helmpilot 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +307 -0
- package/__tests__/config-adapter.test.ts +249 -0
- package/__tests__/outbound.test.ts +147 -0
- package/__tests__/setup-adapter.test.ts +138 -0
- package/adapters.ts +242 -0
- package/bridge.ts +220 -0
- package/channel-plugin.ts +295 -0
- package/index.ts +298 -0
- package/openclaw.plugin.json +67 -0
- package/outbound.ts +98 -0
- package/package.json +38 -0
- package/preload.cjs +33 -0
- package/relay-client.ts +380 -0
- package/relay-registry.ts +32 -0
- package/scripts/link-sdk-core.cjs +124 -0
- package/setup-entry.ts +193 -0
- package/tools.ts +121 -0
- package/types.ts +93 -0
package/README.md
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# helmpilot-channel
|
|
2
|
+
|
|
3
|
+
The Helmpilot Desktop Channel plugin for OpenClaw — registers Helmpilot as a full-featured channel
|
|
4
|
+
in the OpenClaw gateway, providing interactive tools, outbound messaging, and relay-based
|
|
5
|
+
cross-network tunneling.
|
|
6
|
+
|
|
7
|
+
**Channel ID**: `helmpilot-openclaw`
|
|
8
|
+
**Config Section**: `channels.helmpilot`
|
|
9
|
+
|
|
10
|
+
## Architecture
|
|
11
|
+
|
|
12
|
+
Helmpilot is a desktop client that communicates with OpenClaw via WebSocket RPC.
|
|
13
|
+
This plugin makes Helmpilot a **first-class channel** in the OpenClaw ecosystem,
|
|
14
|
+
following the same plugin pattern as Slack, Telegram, Discord, and other channels.
|
|
15
|
+
|
|
16
|
+
### Relay Tunnel
|
|
17
|
+
|
|
18
|
+
When configured with a relay URL, the plugin establishes a transparent tunnel:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Helmpilot Desktop ──WS(deviceKey)──→ Helmpilot Relay ←──WS(channelKey)── helmpilot-channel plugin
|
|
22
|
+
(transparent) │
|
|
23
|
+
bridge WS
|
|
24
|
+
↕
|
|
25
|
+
ws://127.0.0.1:<port>
|
|
26
|
+
(local gateway)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The relay forwards raw WebSocket messages without parsing. The plugin acts as a bridge:
|
|
30
|
+
it connects to the relay as the "gateway side", then opens a local WebSocket to the
|
|
31
|
+
OpenClaw gateway. Messages are transparently forwarded between these two connections.
|
|
32
|
+
|
|
33
|
+
### Interactive Tool Flow
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
Agent calls hp_ask_user({ questions: [...] })
|
|
37
|
+
↓
|
|
38
|
+
OpenClaw emits tool.start event → Helmpilot client renders QuestionCard UI
|
|
39
|
+
↓
|
|
40
|
+
Plugin execute() blocks on a Promise (keyed by toolCallId)
|
|
41
|
+
↓
|
|
42
|
+
User fills answers → Helmpilot client calls helmpilot.respond via WS RPC
|
|
43
|
+
↓
|
|
44
|
+
Gateway method resolves the Promise → returns answers as tool result
|
|
45
|
+
↓
|
|
46
|
+
Agent sees actual user answers, continues reasoning
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Plugin Structure
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
plugins/helmpilot-channel/
|
|
53
|
+
index.ts ← Plugin entry (definePluginEntry) — tools, RPC, channel registration
|
|
54
|
+
channel-plugin.ts ← ChannelPlugin definition (meta/config/gateway/startAccount)
|
|
55
|
+
tools.ts ← Tool schemas, PendingMap (Symbol.for key)
|
|
56
|
+
bridge.ts ← LocalBridge — WS tunnel to local gateway with ping/pong heartbeat
|
|
57
|
+
outbound.ts ← Outbound adapter (sendText/sendMedia via relay)
|
|
58
|
+
relay-client.ts ← RelayClient with Ed25519 auth + auto-reconnect + keepalive
|
|
59
|
+
relay-registry.ts ← Module-level Map<accountId, RelayClient> singleton
|
|
60
|
+
adapters.ts ← Messaging/Setup/Status/Pairing/Security adapters
|
|
61
|
+
types.ts ← Question/Answer TypeBox schemas
|
|
62
|
+
preload.cjs ← CJS→ESM bridge + SDK symlink
|
|
63
|
+
openclaw.plugin.json ← Manifest (declares channel: "helmpilot-openclaw")
|
|
64
|
+
package.json ← Package metadata and OpenClaw compatibility
|
|
65
|
+
__tests__/ ← Outbound adapter + config adapter tests
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Channel Adapters
|
|
69
|
+
|
|
70
|
+
The plugin implements the full ChannelPlugin interface:
|
|
71
|
+
|
|
72
|
+
| Adapter | Source | Purpose |
|
|
73
|
+
|---------|--------|---------|
|
|
74
|
+
| **outbound** | `outbound.ts` | Send text/media to Helmpilot client via relay (`deliveryMode: 'direct'`, `textChunkLimit: 4000`) |
|
|
75
|
+
| **messaging** | `adapters.ts` | Target normalization and resolution |
|
|
76
|
+
| **setup** | `adapters.ts` | Account config validation and application |
|
|
77
|
+
| **status** | `adapters.ts` | Channel summary, account snapshot, probe |
|
|
78
|
+
| **pairing** | `adapters.ts` | Device key pairing (`idLabel: 'Device Key'`) |
|
|
79
|
+
| **security** | `adapters.ts` | Security policy adapter |
|
|
80
|
+
| **config** | Inline in plugin | Account CRUD, enable/disable, account resolution |
|
|
81
|
+
|
|
82
|
+
### Outbound Adapter
|
|
83
|
+
|
|
84
|
+
Sends messages as OpenClaw event frames through the relay:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// Format sent to relay
|
|
88
|
+
{
|
|
89
|
+
type: 'event',
|
|
90
|
+
event: 'helmpilot.outbound',
|
|
91
|
+
payload: {
|
|
92
|
+
kind: 'text' | 'media',
|
|
93
|
+
text?: string,
|
|
94
|
+
mediaUrl?: string,
|
|
95
|
+
messageId: string, // Format: msg_<uuid>
|
|
96
|
+
replyToId?: string,
|
|
97
|
+
identity?: { name?, avatar?, emoji? }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Tools
|
|
103
|
+
|
|
104
|
+
### `hp_ask_user`
|
|
105
|
+
|
|
106
|
+
Present structured questions to the user in the Helmpilot desktop client.
|
|
107
|
+
Blocks until the user submits answers. No fixed timeout — waits indefinitely
|
|
108
|
+
until the user responds. Cleanup relies on connection/session lifecycle boundaries.
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
{
|
|
112
|
+
questions: [
|
|
113
|
+
{
|
|
114
|
+
header: "Language", // Short unique ID (max 50 chars)
|
|
115
|
+
question: "Which language?", // Display text (max 500 chars)
|
|
116
|
+
options: [ // Optional predefined choices
|
|
117
|
+
{ label: "Python", recommended: true },
|
|
118
|
+
{ label: "TypeScript" },
|
|
119
|
+
{ label: "Rust", description: "Systems programming" }
|
|
120
|
+
],
|
|
121
|
+
multiSelect: false, // Allow multiple selections
|
|
122
|
+
allowFreeformInput: true // Allow typing a custom answer
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `hp_send_msg`
|
|
129
|
+
|
|
130
|
+
Send a notification or structured message to the Helmpilot client.
|
|
131
|
+
Non-blocking — delivers the message for display as a distinct notification card,
|
|
132
|
+
separate from inline chat stream. Supports Markdown in the message body.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
{
|
|
136
|
+
message: "Deployment **completed** successfully!", // Markdown content
|
|
137
|
+
type: "success", // "info" (default) | "success" | "warning" | "error"
|
|
138
|
+
title: "Deploy Status" // Optional notification title
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `hp_send_file`
|
|
143
|
+
|
|
144
|
+
Send a file to the Helmpilot client for local display/writing.
|
|
145
|
+
Non-blocking — the tool returns immediately after relaying the content.
|
|
146
|
+
|
|
147
|
+
## Gateway RPC Methods
|
|
148
|
+
|
|
149
|
+
### `helmpilot.respond`
|
|
150
|
+
|
|
151
|
+
Called by the Helmpilot client to deliver user answers to a pending `hp_ask_user` call.
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{ "toolCallId": "...", "answers": "User's formatted answer string" }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Configuration
|
|
158
|
+
|
|
159
|
+
### CLI Quick Setup
|
|
160
|
+
|
|
161
|
+
The fastest way to configure helmpilot-channel is via the OpenClaw CLI:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Full non-interactive setup (all three fields at once)
|
|
165
|
+
openclaw channels add --channel helmpilot-openclaw \
|
|
166
|
+
--url wss://relay.example.com \
|
|
167
|
+
--code helmpilot-abc123 \
|
|
168
|
+
--token ck_xyz789
|
|
169
|
+
|
|
170
|
+
# Interactive wizard mode (guided step-by-step)
|
|
171
|
+
openclaw channels add --channel helmpilot-openclaw
|
|
172
|
+
|
|
173
|
+
# Partial update (e.g. rotate channel key only)
|
|
174
|
+
openclaw channels add --channel helmpilot-openclaw --token ck_new_key
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
| CLI Flag | Config Field | Validation | Example |
|
|
178
|
+
|----------|-------------|------------|---------|
|
|
179
|
+
| `--url` | `channels.helmpilot.relayUrl` | Must be `ws://`, `wss://`, `http://`, or `https://` | `wss://relay.example.com` |
|
|
180
|
+
| `--code` | `channels.helmpilot.channelId` | Must start with `helmpilot-` | `helmpilot-abc123` |
|
|
181
|
+
| `--token` | `channels.helmpilot.channelKey` | Must start with `ck_` | `ck_xyz789` |
|
|
182
|
+
|
|
183
|
+
> **Tip**: The `channelId` and `channelKey` are generated by the Relay server during
|
|
184
|
+
> provisioning (triggered from the Helmpilot desktop client). Copy them from the Helmpilot
|
|
185
|
+
> setup screen into the CLI command above.
|
|
186
|
+
|
|
187
|
+
### Manual Configuration
|
|
188
|
+
|
|
189
|
+
Add to your OpenClaw config (`openclaw.json`):
|
|
190
|
+
|
|
191
|
+
### Single account (simple)
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{
|
|
195
|
+
"channels": {
|
|
196
|
+
"helmpilot": {
|
|
197
|
+
"enabled": true,
|
|
198
|
+
"relayUrl": "wss://relay.example.com",
|
|
199
|
+
"channelKey": "ck_xxxxx..."
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Multi-account
|
|
206
|
+
|
|
207
|
+
```json
|
|
208
|
+
{
|
|
209
|
+
"channels": {
|
|
210
|
+
"helmpilot": {
|
|
211
|
+
"accounts": {
|
|
212
|
+
"work": { "relayUrl": "wss://relay.example.com", "channelKey": "ck_aaa..." },
|
|
213
|
+
"home": { "relayUrl": "wss://relay.example.com", "channelKey": "ck_bbb..." }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Secret storage for channelKey
|
|
221
|
+
|
|
222
|
+
To avoid storing channelKey in plaintext, use any of these formats:
|
|
223
|
+
|
|
224
|
+
```json
|
|
225
|
+
// Env template — resolved from process.env at startup
|
|
226
|
+
"channelKey": "${HELMPILOT_CHANNEL_KEY}"
|
|
227
|
+
|
|
228
|
+
// SecretRef (env source)
|
|
229
|
+
"channelKey": { "source": "env", "id": "HELMPILOT_CHANNEL_KEY" }
|
|
230
|
+
|
|
231
|
+
// SecretRef (file source)
|
|
232
|
+
"channelKey": { "source": "file", "id": "/run/secrets/helmpilot-channel-key" }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
| Field | Type | Required | Purpose |
|
|
236
|
+
|-------|------|----------|---------|
|
|
237
|
+
| `enabled` | boolean | No (default: true) | Enable/disable the channel |
|
|
238
|
+
| `relayUrl` | string | Yes (for relay mode) | Relay service WebSocket URL |
|
|
239
|
+
| `channelId` | string | Auto-populated | Channel ID from relay registration |
|
|
240
|
+
| `channelKey` | string \| SecretRef | Yes (for relay mode) | Channel key — supports env templates and SecretRef |
|
|
241
|
+
|
|
242
|
+
## Installation
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
openclaw plugins install helmpilot-channel
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Or add to your workspace's plugin load paths:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
# Local development (symlink)
|
|
252
|
+
ln -s /path/to/Helmpilot/plugins/helmpilot-channel ~/.openclaw/extensions/helmpilot-channel
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Relay Client
|
|
256
|
+
|
|
257
|
+
`RelayClient` (`relay-client.ts`) manages the gateway-side WebSocket connection to the relay:
|
|
258
|
+
|
|
259
|
+
- **Ed25519 auth**: Auto-generates key pair, handles challenge-response with relay
|
|
260
|
+
- **Legacy fallback**: Falls back to `?key=<channelKey>` URL param if Ed25519 keys not registered
|
|
261
|
+
- **Auto-reconnect**: Exponential backoff from 1s to 30s
|
|
262
|
+
- **Keepalive**: Sends `{"__relay":true,"type":"ping"}` every 25 seconds
|
|
263
|
+
- **Flush handling**: Recognizes `flush_start`/`flush_end` relay markers for buffered message batches
|
|
264
|
+
- **States**: `'disconnected' | 'connecting' | 'authenticating' | 'connected' | 'error'`
|
|
265
|
+
|
|
266
|
+
## Local Bridge
|
|
267
|
+
|
|
268
|
+
`LocalBridge` (`bridge.ts`) manages the WS connection to the local OpenClaw gateway:
|
|
269
|
+
|
|
270
|
+
- **Transparent tunnel**: Forwards raw WS frames between relay and local gateway
|
|
271
|
+
- **Ping/pong heartbeat**: 20s interval + 10s timeout for dead connection detection
|
|
272
|
+
- **Lifecycle**: Opened per `startAccount()`, closed on disconnect
|
|
273
|
+
|
|
274
|
+
## Process Architecture
|
|
275
|
+
|
|
276
|
+
The gateway loads plugin registration twice:
|
|
277
|
+
- **Connection-level**: `registerGatewayMethod()` handlers (client-specific)
|
|
278
|
+
- **Per-agent**: `registerTool()` handlers (agent-scoped)
|
|
279
|
+
|
|
280
|
+
These scopes don't share closure variables. We use `globalThis[Symbol.for('helmpilot.pendingMap')]`
|
|
281
|
+
as a process-level shared mailbox between `helmpilot.respond` (connection) and
|
|
282
|
+
`hp_ask_user` execute (agent).
|
|
283
|
+
|
|
284
|
+
## Multi-Account Isolation
|
|
285
|
+
|
|
286
|
+
Each account gets its own `RelayClient` + `LocalBridge` pair, registered in `relay-registry.ts`.
|
|
287
|
+
Outbound messages are routed to the correct relay via `getRelay(accountId)` — the null-fallback
|
|
288
|
+
has been removed to prevent cross-account message leakage.
|
|
289
|
+
|
|
290
|
+
The `helmpilot.respond` RPC handler requires an explicit `toolCallId` to prevent resolving
|
|
291
|
+
a pending request from a different account's agent.
|
|
292
|
+
|
|
293
|
+
## Testing
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
# All plugin tests
|
|
297
|
+
npx vitest run plugins/helmpilot-channel/__tests__/
|
|
298
|
+
|
|
299
|
+
# Individual test files
|
|
300
|
+
npx vitest run plugins/helmpilot-channel/__tests__/outbound.test.ts # Outbound adapter (10 cases)
|
|
301
|
+
npx vitest run plugins/helmpilot-channel/__tests__/config-adapter.test.ts # Config/multi-account (varies)
|
|
302
|
+
npx vitest run plugins/helmpilot-channel/__tests__/setup-adapter.test.ts # CLI setup adapter (16 cases)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## License
|
|
306
|
+
|
|
307
|
+
MIT
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the helmpilot-channel plugin config adapter (multi-account support).
|
|
3
|
+
*
|
|
4
|
+
* We import the plugin definition and exercise the config.* methods
|
|
5
|
+
* to verify multi-account and legacy single-account config parsing.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
8
|
+
|
|
9
|
+
// Mock all external dependencies that the plugin imports
|
|
10
|
+
vi.mock('../relay-registry.js', () => ({
|
|
11
|
+
getRelay: vi.fn(),
|
|
12
|
+
getFirstRelay: vi.fn(),
|
|
13
|
+
registerRelay: vi.fn(),
|
|
14
|
+
unregisterRelay: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('node:crypto', () => ({ randomUUID: () => 'test-uuid' }));
|
|
17
|
+
|
|
18
|
+
vi.mock('ws', () => ({
|
|
19
|
+
WebSocket: class MockWebSocket {},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../relay-client.js', () => ({
|
|
23
|
+
RelayClient: class MockRelayClient {
|
|
24
|
+
constructor() {}
|
|
25
|
+
connect() {}
|
|
26
|
+
close() {}
|
|
27
|
+
getState() { return 'disconnected'; }
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../outbound.js', () => ({
|
|
32
|
+
createOutboundAdapter: () => ({ sendText: vi.fn(), sendMedia: vi.fn() }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../adapters.js', () => ({
|
|
36
|
+
createMessagingAdapter: () => ({}),
|
|
37
|
+
createSetupAdapter: () => ({}),
|
|
38
|
+
createStatusAdapter: () => ({}),
|
|
39
|
+
createPairingAdapter: () => ({}),
|
|
40
|
+
createSecurityAdapter: () => ({}),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// The plugin file imports from OpenClaw SDK — mock these
|
|
44
|
+
vi.mock('openclaw/plugin-sdk/plugin-entry', () => ({
|
|
45
|
+
definePluginEntry: (fn: any) => fn,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock('openclaw/plugin-sdk/core', () => ({
|
|
49
|
+
ChannelPlugin: class {},
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
vi.mock('openclaw/plugin-sdk/device-bootstrap', () => ({
|
|
53
|
+
listDevicePairing: vi.fn().mockResolvedValue({ pending: [], paired: [] }),
|
|
54
|
+
approveDevicePairing: vi.fn().mockResolvedValue(null),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// Import after mocks are set up
|
|
58
|
+
const pluginEntry = (await import('../index.js')).default;
|
|
59
|
+
|
|
60
|
+
// The plugin registers the channel via api.registerChannel({ plugin: helmpilotPlugin })
|
|
61
|
+
// We capture helmpilotPlugin by calling register() with a spy
|
|
62
|
+
let helmpilotPlugin: any;
|
|
63
|
+
const fakeApi = {
|
|
64
|
+
registerChannel: (opts: any) => { helmpilotPlugin = opts.plugin; },
|
|
65
|
+
registerGatewayMethod: () => {},
|
|
66
|
+
registerTool: () => {},
|
|
67
|
+
on: () => {},
|
|
68
|
+
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
|
69
|
+
};
|
|
70
|
+
pluginEntry.register(fakeApi);
|
|
71
|
+
|
|
72
|
+
const cfg = helmpilotPlugin.config;
|
|
73
|
+
|
|
74
|
+
// ── Helpers ──
|
|
75
|
+
|
|
76
|
+
/** Build a full OpenClaw config shape with the given helmpilot section */
|
|
77
|
+
function makeConfig(helmpilot: Record<string, unknown> = {}) {
|
|
78
|
+
return { channels: { helmpilot } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('config adapter', () => {
|
|
82
|
+
describe('listAccountIds', () => {
|
|
83
|
+
it('returns ["default"] for empty config', () => {
|
|
84
|
+
const ids = cfg.listAccountIds({});
|
|
85
|
+
expect(ids).toEqual(['default']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns ["default"] for legacy single-account config', () => {
|
|
89
|
+
const ids = cfg.listAccountIds(
|
|
90
|
+
makeConfig({ relayUrl: 'https://relay.example.com', channelKey: 'ck_abc' }),
|
|
91
|
+
);
|
|
92
|
+
expect(ids).toEqual(['default']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns account keys from accounts object', () => {
|
|
96
|
+
const ids = cfg.listAccountIds(
|
|
97
|
+
makeConfig({
|
|
98
|
+
accounts: {
|
|
99
|
+
home: { relayUrl: 'https://r1.com', channelKey: 'ck_1' },
|
|
100
|
+
work: { relayUrl: 'https://r2.com', channelKey: 'ck_2' },
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
expect(ids).toEqual(['home', 'work']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('prepends "default" when legacy top-level config coexists with accounts', () => {
|
|
108
|
+
const ids = cfg.listAccountIds(
|
|
109
|
+
makeConfig({
|
|
110
|
+
relayUrl: 'https://legacy.com',
|
|
111
|
+
channelKey: 'ck_legacy',
|
|
112
|
+
accounts: {
|
|
113
|
+
mobile: { relayUrl: 'https://r1.com', channelKey: 'ck_1' },
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
expect(ids).toEqual(['default', 'mobile']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not duplicate default if accounts already has "default" key', () => {
|
|
121
|
+
const ids = cfg.listAccountIds(
|
|
122
|
+
makeConfig({
|
|
123
|
+
relayUrl: 'https://legacy.com',
|
|
124
|
+
channelKey: 'ck_legacy',
|
|
125
|
+
accounts: {
|
|
126
|
+
default: { relayUrl: 'https://r1.com', channelKey: 'ck_1' },
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
expect(ids).toEqual(['default']);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('resolveAccount', () => {
|
|
135
|
+
it('resolves legacy single-account with top-level config', () => {
|
|
136
|
+
const account = cfg.resolveAccount(
|
|
137
|
+
makeConfig({ relayUrl: 'https://relay.example.com', channelKey: 'ck_abc' }),
|
|
138
|
+
'default',
|
|
139
|
+
);
|
|
140
|
+
expect(account.accountId).toBe('default');
|
|
141
|
+
expect(account.config.relayUrl).toBe('https://relay.example.com');
|
|
142
|
+
expect(account.config.channelKey).toBe('ck_abc');
|
|
143
|
+
expect(account.config.enabled).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('resolves account from multi-account config', () => {
|
|
147
|
+
const account = cfg.resolveAccount(
|
|
148
|
+
makeConfig({
|
|
149
|
+
accounts: {
|
|
150
|
+
home: { relayUrl: 'https://r1.com', channelKey: 'ck_1' },
|
|
151
|
+
work: { relayUrl: 'https://r2.com', channelKey: 'ck_2' },
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
'work',
|
|
155
|
+
);
|
|
156
|
+
expect(account.accountId).toBe('work');
|
|
157
|
+
expect(account.config.relayUrl).toBe('https://r2.com');
|
|
158
|
+
expect(account.config.channelKey).toBe('ck_2');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('falls back to legacy config for unknown account ID', () => {
|
|
162
|
+
const account = cfg.resolveAccount(
|
|
163
|
+
makeConfig({ relayUrl: 'https://legacy.com', channelKey: 'ck_legacy' }),
|
|
164
|
+
'nonexistent',
|
|
165
|
+
);
|
|
166
|
+
expect(account.accountId).toBe('nonexistent');
|
|
167
|
+
expect(account.config.relayUrl).toBe('https://legacy.com');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('defaults accountId to "default" when null', () => {
|
|
171
|
+
const account = cfg.resolveAccount(
|
|
172
|
+
makeConfig({ relayUrl: 'https://r.com', channelKey: 'ck_x' }),
|
|
173
|
+
null as any,
|
|
174
|
+
);
|
|
175
|
+
expect(account.accountId).toBe('default');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('respects per-account enabled=false', () => {
|
|
179
|
+
const account = cfg.resolveAccount(
|
|
180
|
+
makeConfig({
|
|
181
|
+
accounts: {
|
|
182
|
+
off: { enabled: false, relayUrl: 'https://r.com', channelKey: 'ck_x' },
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
'off',
|
|
186
|
+
);
|
|
187
|
+
expect(account.config.enabled).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('respects top-level enabled=false for multi-account', () => {
|
|
191
|
+
const account = cfg.resolveAccount(
|
|
192
|
+
makeConfig({
|
|
193
|
+
enabled: false,
|
|
194
|
+
accounts: {
|
|
195
|
+
home: { relayUrl: 'https://r.com', channelId: 'helmpilot-home', channelKey: 'ck_x' },
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
'home',
|
|
199
|
+
);
|
|
200
|
+
expect(account.config.enabled).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('isConfigured', () => {
|
|
205
|
+
it('returns true for relay config with url, channelId, and key', () => {
|
|
206
|
+
const account = cfg.resolveAccount(
|
|
207
|
+
makeConfig({ relayUrl: 'https://r.com', channelId: 'helmpilot-abc', channelKey: 'ck_x' }),
|
|
208
|
+
'default',
|
|
209
|
+
);
|
|
210
|
+
expect(cfg.isConfigured(account)).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('returns false when only relayUrl is set', () => {
|
|
214
|
+
const account = cfg.resolveAccount(
|
|
215
|
+
makeConfig({ relayUrl: 'https://r.com' }),
|
|
216
|
+
'default',
|
|
217
|
+
);
|
|
218
|
+
expect(cfg.isConfigured(account)).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('returns false when channelId is missing', () => {
|
|
222
|
+
const account = cfg.resolveAccount(
|
|
223
|
+
makeConfig({ relayUrl: 'https://r.com', channelKey: 'ck_x' }),
|
|
224
|
+
'default',
|
|
225
|
+
);
|
|
226
|
+
expect(cfg.isConfigured(account)).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('returns true for local mode (no relay config)', () => {
|
|
230
|
+
const account = cfg.resolveAccount(makeConfig({}), 'default');
|
|
231
|
+
expect(cfg.isConfigured(account)).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('isEnabled', () => {
|
|
236
|
+
it('returns true by default', () => {
|
|
237
|
+
const account = cfg.resolveAccount(makeConfig({}), 'default');
|
|
238
|
+
expect(cfg.isEnabled(account)).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('returns false when enabled is explicitly false', () => {
|
|
242
|
+
const account = cfg.resolveAccount(
|
|
243
|
+
makeConfig({ enabled: false }),
|
|
244
|
+
'default',
|
|
245
|
+
);
|
|
246
|
+
expect(cfg.isEnabled(account)).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Use vi.hoisted so mock variables are available during vi.mock hoisting
|
|
4
|
+
const { mockGetRelay, uuidState } = vi.hoisted(() => ({
|
|
5
|
+
mockGetRelay: vi.fn(),
|
|
6
|
+
uuidState: { counter: 0 },
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('../relay-registry.js', () => ({
|
|
10
|
+
getRelay: mockGetRelay,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('node:crypto', () => ({
|
|
14
|
+
randomUUID: () => `00000000-0000-0000-0000-${String(++uuidState.counter).padStart(12, '0')}`,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { createOutboundAdapter } from '../outbound';
|
|
18
|
+
|
|
19
|
+
describe('outbound adapter', () => {
|
|
20
|
+
const mockRelay = {
|
|
21
|
+
getState: vi.fn(),
|
|
22
|
+
sendRaw: vi.fn(),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
uuidState.counter = 0;
|
|
28
|
+
mockGetRelay.mockReturnValue(mockRelay);
|
|
29
|
+
mockRelay.getState.mockReturnValue('connected');
|
|
30
|
+
mockRelay.sendRaw.mockReturnValue(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('sendText', () => {
|
|
34
|
+
it('sends text as OpenClaw event frame format', async () => {
|
|
35
|
+
const adapter = createOutboundAdapter();
|
|
36
|
+
const result = await adapter.sendText({
|
|
37
|
+
to: 'user1',
|
|
38
|
+
text: 'Hello from agent',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result.channel).toBe('helmpilot');
|
|
42
|
+
expect(result.messageId).toMatch(/^msg_/);
|
|
43
|
+
expect(result.error).toBeUndefined();
|
|
44
|
+
|
|
45
|
+
const sent = JSON.parse(mockRelay.sendRaw.mock.calls[0][0]);
|
|
46
|
+
expect(sent.type).toBe('event');
|
|
47
|
+
expect(sent.event).toBe('helmpilot.outbound');
|
|
48
|
+
expect(sent.payload.kind).toBe('text');
|
|
49
|
+
expect(sent.payload.text).toBe('Hello from agent');
|
|
50
|
+
expect(sent.payload.messageId).toMatch(/^msg_/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('includes identity and replyToId when provided', async () => {
|
|
54
|
+
const adapter = createOutboundAdapter();
|
|
55
|
+
await adapter.sendText({
|
|
56
|
+
to: 'user1',
|
|
57
|
+
text: 'Reply',
|
|
58
|
+
replyToId: 'orig_123',
|
|
59
|
+
identity: { name: 'Bot', emoji: '🤖' },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const sent = JSON.parse(mockRelay.sendRaw.mock.calls[0][0]);
|
|
63
|
+
expect(sent.payload.replyToId).toBe('orig_123');
|
|
64
|
+
expect(sent.payload.identity).toEqual({ name: 'Bot', emoji: '🤖' });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns error when relay is not connected', async () => {
|
|
68
|
+
mockRelay.getState.mockReturnValue('connecting');
|
|
69
|
+
const adapter = createOutboundAdapter();
|
|
70
|
+
const result = await adapter.sendText({ to: 'user1', text: 'Hi' });
|
|
71
|
+
|
|
72
|
+
expect(result.error).toBe('Relay not connected');
|
|
73
|
+
expect(result.messageId).toBe('');
|
|
74
|
+
expect(mockRelay.sendRaw).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns error when relay is null', async () => {
|
|
78
|
+
mockGetRelay.mockReturnValue(null);
|
|
79
|
+
const adapter = createOutboundAdapter();
|
|
80
|
+
const result = await adapter.sendText({ to: 'user1', text: 'Hi' });
|
|
81
|
+
|
|
82
|
+
expect(result.error).toBe('Relay not connected');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns error when sendRaw fails', async () => {
|
|
86
|
+
mockRelay.sendRaw.mockReturnValue(false);
|
|
87
|
+
const adapter = createOutboundAdapter();
|
|
88
|
+
const result = await adapter.sendText({ to: 'user1', text: 'Hi' });
|
|
89
|
+
|
|
90
|
+
expect(result.error).toBe('Failed to send via relay');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('sendMedia', () => {
|
|
95
|
+
it('sends media as OpenClaw event frame format', async () => {
|
|
96
|
+
const adapter = createOutboundAdapter();
|
|
97
|
+
const result = await adapter.sendMedia({
|
|
98
|
+
to: 'user1',
|
|
99
|
+
mediaUrl: 'https://example.com/image.png',
|
|
100
|
+
text: 'Check this out',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.channel).toBe('helmpilot');
|
|
104
|
+
expect(result.error).toBeUndefined();
|
|
105
|
+
|
|
106
|
+
const sent = JSON.parse(mockRelay.sendRaw.mock.calls[0][0]);
|
|
107
|
+
expect(sent.type).toBe('event');
|
|
108
|
+
expect(sent.event).toBe('helmpilot.outbound');
|
|
109
|
+
expect(sent.payload.kind).toBe('media');
|
|
110
|
+
expect(sent.payload.mediaUrl).toBe('https://example.com/image.png');
|
|
111
|
+
expect(sent.payload.text).toBe('Check this out');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('sends media without text', async () => {
|
|
115
|
+
const adapter = createOutboundAdapter();
|
|
116
|
+
await adapter.sendMedia({
|
|
117
|
+
to: 'user1',
|
|
118
|
+
mediaUrl: 'https://example.com/file.pdf',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const sent = JSON.parse(mockRelay.sendRaw.mock.calls[0][0]);
|
|
122
|
+
expect(sent.payload.text).toBeUndefined();
|
|
123
|
+
expect(sent.payload.mediaUrl).toBe('https://example.com/file.pdf');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns error when relay is not connected', async () => {
|
|
127
|
+
mockGetRelay.mockReturnValue(null);
|
|
128
|
+
const adapter = createOutboundAdapter();
|
|
129
|
+
const result = await adapter.sendMedia({
|
|
130
|
+
to: 'user1',
|
|
131
|
+
mediaUrl: 'https://example.com/image.png',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.error).toBe('Relay not connected');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('deliveryMode is direct', () => {
|
|
139
|
+
const adapter = createOutboundAdapter();
|
|
140
|
+
expect(adapter.deliveryMode).toBe('direct');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('textChunkLimit is 4000', () => {
|
|
144
|
+
const adapter = createOutboundAdapter();
|
|
145
|
+
expect(adapter.textChunkLimit).toBe(4000);
|
|
146
|
+
});
|
|
147
|
+
});
|