@twsxtd/hapi-openclaw 0.1.0 → 0.1.1
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 +9 -4
- package/dist/index.js +414 -167
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,14 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Native OpenClaw plugin adapter for HAPI integration work.
|
|
4
4
|
|
|
5
|
-
This package
|
|
5
|
+
This package installs as an OpenClaw native plugin and exposes the HAPI-facing `/hapi/*` route surface from inside the OpenClaw Gateway. V1 now creates or resumes real OpenClaw sessions, starts real embedded-agent runs, and forwards real assistant transcript messages back into HAPI. Approval bridging is still deferred.
|
|
6
6
|
|
|
7
7
|
What it does:
|
|
8
8
|
|
|
9
9
|
- exposes `/hapi/health` and `/hapi/channel/*` through `api.registerHttpRoute(...)`
|
|
10
10
|
- enforces plugin-managed bearer auth
|
|
11
11
|
- signs callback events back to HAPI
|
|
12
|
-
-
|
|
12
|
+
- derives deterministic OpenClaw session keys from HAPI namespace + user key
|
|
13
|
+
- starts real OpenClaw embedded-agent runs for `send-message`
|
|
14
|
+
- bridges assistant transcript text updates into HAPI `message` / `state` callbacks
|
|
15
|
+
- returns `501` for approval endpoints until the real approval bridge lands
|
|
13
16
|
- records real transcript-update payloads to plugin state when `prototypeCaptureSessionKey` is configured
|
|
14
17
|
|
|
15
18
|
Plugin config lives under `plugins.entries.hapi-openclaw.config` in OpenClaw config:
|
|
@@ -53,7 +56,7 @@ Example OpenClaw config:
|
|
|
53
56
|
"hapiBaseUrl": "http://127.0.0.1:3006",
|
|
54
57
|
"sharedSecret": "test-secret",
|
|
55
58
|
"namespace": "default",
|
|
56
|
-
"prototypeCaptureSessionKey": "hapi-openclaw:default:debug-user"
|
|
59
|
+
"prototypeCaptureSessionKey": "agent:main:hapi-openclaw:default:debug-user"
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
}
|
|
@@ -65,4 +68,6 @@ Current milestone note:
|
|
|
65
68
|
|
|
66
69
|
- HAPI official mode should point `OPENCLAW_PLUGIN_BASE_URL` at the OpenClaw Gateway base URL
|
|
67
70
|
- the plugin route surface is native now
|
|
68
|
-
-
|
|
71
|
+
- `ensure-default-conversation` and `send-message` use the real OpenClaw runtime now
|
|
72
|
+
- assistant replies in HAPI come from real OpenClaw transcript updates, not mock text
|
|
73
|
+
- approval request / approve / deny bridging is still not implemented in this milestone
|
package/dist/index.js
CHANGED
|
@@ -163,6 +163,233 @@ async function forwardNodeRequestToHono(app, req, res) {
|
|
|
163
163
|
return true;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// src/openclawAdapter.ts
|
|
167
|
+
import { randomUUID } from "node:crypto";
|
|
168
|
+
|
|
169
|
+
// src/adapterState.ts
|
|
170
|
+
var activeRuns = new Set;
|
|
171
|
+
var seenTranscriptMessageIds = new Set;
|
|
172
|
+
var adapterState = {
|
|
173
|
+
startRun(sessionKey) {
|
|
174
|
+
if (activeRuns.has(sessionKey)) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
activeRuns.add(sessionKey);
|
|
178
|
+
return true;
|
|
179
|
+
},
|
|
180
|
+
isRunActive(sessionKey) {
|
|
181
|
+
return activeRuns.has(sessionKey);
|
|
182
|
+
},
|
|
183
|
+
finishRun(sessionKey) {
|
|
184
|
+
return activeRuns.delete(sessionKey);
|
|
185
|
+
},
|
|
186
|
+
rememberTranscriptMessage(messageId) {
|
|
187
|
+
if (seenTranscriptMessageIds.has(messageId)) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
seenTranscriptMessageIds.add(messageId);
|
|
191
|
+
return true;
|
|
192
|
+
},
|
|
193
|
+
resetForTests() {
|
|
194
|
+
activeRuns.clear();
|
|
195
|
+
seenTranscriptMessageIds.clear();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// src/sessionKeys.ts
|
|
200
|
+
import { createHash } from "node:crypto";
|
|
201
|
+
var HAPI_SESSION_PREFIX = "hapi-openclaw";
|
|
202
|
+
var DEFAULT_AGENT_ID = "main";
|
|
203
|
+
var REPLY_TO_CURRENT_PREFIX = "[[reply_to_current]]";
|
|
204
|
+
function encodeUserKey(externalUserKey) {
|
|
205
|
+
const normalized = externalUserKey.trim();
|
|
206
|
+
if (!normalized) {
|
|
207
|
+
throw new Error("externalUserKey must be a non-empty string");
|
|
208
|
+
}
|
|
209
|
+
return encodeURIComponent(normalized);
|
|
210
|
+
}
|
|
211
|
+
function getDefaultAgentId() {
|
|
212
|
+
return DEFAULT_AGENT_ID;
|
|
213
|
+
}
|
|
214
|
+
function buildHapiConversationToken(namespace, externalUserKey) {
|
|
215
|
+
return `${HAPI_SESSION_PREFIX}:${namespace}:${encodeUserKey(externalUserKey)}`;
|
|
216
|
+
}
|
|
217
|
+
function buildHapiSessionKey(namespace, externalUserKey, agentId = DEFAULT_AGENT_ID) {
|
|
218
|
+
return `agent:${agentId}:${buildHapiConversationToken(namespace, externalUserKey)}`;
|
|
219
|
+
}
|
|
220
|
+
function parseHapiSessionKey(sessionKey) {
|
|
221
|
+
if (!sessionKey) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const match = /^agent:([^:]+):hapi-openclaw:([^:]+):(.+)$/.exec(sessionKey.trim());
|
|
225
|
+
if (!match) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
return {
|
|
230
|
+
agentId: match[1],
|
|
231
|
+
namespace: match[2],
|
|
232
|
+
externalUserKey: decodeURIComponent(match[3])
|
|
233
|
+
};
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function deriveDeterministicSessionId(sessionKey) {
|
|
239
|
+
const hex = createHash("sha256").update(sessionKey).digest("hex").slice(0, 32);
|
|
240
|
+
return [
|
|
241
|
+
hex.slice(0, 8),
|
|
242
|
+
hex.slice(8, 12),
|
|
243
|
+
hex.slice(12, 16),
|
|
244
|
+
hex.slice(16, 20),
|
|
245
|
+
hex.slice(20, 32)
|
|
246
|
+
].join("-");
|
|
247
|
+
}
|
|
248
|
+
function stripReplyToCurrentPrefix(text) {
|
|
249
|
+
return text.replace(new RegExp(`^${REPLY_TO_CURRENT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`), "").trim();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/openclawAdapter.ts
|
|
253
|
+
var CONVERSATION_TITLE = "OpenClaw";
|
|
254
|
+
var RUN_COMPLETION_SETTLE_MS = 50;
|
|
255
|
+
|
|
256
|
+
class ConversationBusyError extends Error {
|
|
257
|
+
constructor() {
|
|
258
|
+
super("Conversation already has an active OpenClaw run");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function createStateEvent(params) {
|
|
262
|
+
return {
|
|
263
|
+
type: "state",
|
|
264
|
+
eventId: randomUUID(),
|
|
265
|
+
occurredAt: Date.now(),
|
|
266
|
+
namespace: params.namespace,
|
|
267
|
+
conversationId: params.conversationId,
|
|
268
|
+
connected: true,
|
|
269
|
+
thinking: params.thinking,
|
|
270
|
+
lastError: params.lastError
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function getStateNamespace(sessionKey, fallbackNamespace) {
|
|
274
|
+
return parseHapiSessionKey(sessionKey)?.namespace ?? fallbackNamespace;
|
|
275
|
+
}
|
|
276
|
+
function delay(ms) {
|
|
277
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
278
|
+
}
|
|
279
|
+
async function ensureSessionBinding(runtime, sessionKey, agentId) {
|
|
280
|
+
const storePath = runtime.agent.session.resolveStorePath(undefined, { agentId });
|
|
281
|
+
const store = runtime.agent.session.loadSessionStore(storePath);
|
|
282
|
+
const existing = store[sessionKey];
|
|
283
|
+
const sessionId = existing?.sessionId?.trim() || deriveDeterministicSessionId(sessionKey);
|
|
284
|
+
const sessionFile = runtime.agent.session.resolveSessionFilePath(sessionId, existing, { agentId });
|
|
285
|
+
store[sessionKey] = {
|
|
286
|
+
...existing,
|
|
287
|
+
sessionId,
|
|
288
|
+
sessionFile,
|
|
289
|
+
updatedAt: Date.now(),
|
|
290
|
+
label: existing?.label ?? CONVERSATION_TITLE,
|
|
291
|
+
displayName: existing?.displayName ?? CONVERSATION_TITLE
|
|
292
|
+
};
|
|
293
|
+
await runtime.agent.session.saveSessionStore(storePath, store, {
|
|
294
|
+
activeSessionKey: sessionKey
|
|
295
|
+
});
|
|
296
|
+
return {
|
|
297
|
+
sessionId,
|
|
298
|
+
sessionFile
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
class RealOpenClawAdapter {
|
|
303
|
+
namespace;
|
|
304
|
+
runtime;
|
|
305
|
+
callbackClient;
|
|
306
|
+
supportsApprovals = false;
|
|
307
|
+
constructor(namespace, runtime, callbackClient) {
|
|
308
|
+
this.namespace = namespace;
|
|
309
|
+
this.runtime = runtime;
|
|
310
|
+
this.callbackClient = callbackClient;
|
|
311
|
+
}
|
|
312
|
+
async ensureDefaultConversation(externalUserKey) {
|
|
313
|
+
return {
|
|
314
|
+
conversationId: buildHapiSessionKey(this.namespace, externalUserKey, getDefaultAgentId()),
|
|
315
|
+
title: CONVERSATION_TITLE
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
isConversationBusy(conversationId) {
|
|
319
|
+
return adapterState.isRunActive(conversationId);
|
|
320
|
+
}
|
|
321
|
+
async sendMessage(action) {
|
|
322
|
+
if (!adapterState.startRun(action.conversationId)) {
|
|
323
|
+
throw new ConversationBusyError;
|
|
324
|
+
}
|
|
325
|
+
const namespace = getStateNamespace(action.conversationId, this.namespace);
|
|
326
|
+
await this.callbackClient.postEvent(createStateEvent({
|
|
327
|
+
namespace,
|
|
328
|
+
conversationId: action.conversationId,
|
|
329
|
+
thinking: true,
|
|
330
|
+
lastError: null
|
|
331
|
+
}));
|
|
332
|
+
try {
|
|
333
|
+
const config = this.runtime.config.loadConfig();
|
|
334
|
+
const agentId = parseHapiSessionKey(action.conversationId)?.agentId ?? getDefaultAgentId();
|
|
335
|
+
const workspaceDir = this.runtime.agent.resolveAgentWorkspaceDir(config, agentId);
|
|
336
|
+
await this.runtime.agent.ensureAgentWorkspace({ dir: workspaceDir });
|
|
337
|
+
const { sessionId, sessionFile } = await ensureSessionBinding(this.runtime, action.conversationId, agentId);
|
|
338
|
+
const result = await this.runtime.agent.runEmbeddedAgent({
|
|
339
|
+
sessionId,
|
|
340
|
+
sessionKey: action.conversationId,
|
|
341
|
+
sessionFile,
|
|
342
|
+
workspaceDir,
|
|
343
|
+
agentId,
|
|
344
|
+
prompt: action.text,
|
|
345
|
+
timeoutMs: this.runtime.agent.resolveAgentTimeoutMs({ cfg: config }),
|
|
346
|
+
runId: randomUUID(),
|
|
347
|
+
trigger: "user"
|
|
348
|
+
});
|
|
349
|
+
const runError = result.meta.error?.message?.trim() || null;
|
|
350
|
+
if (runError) {
|
|
351
|
+
if (adapterState.finishRun(action.conversationId)) {
|
|
352
|
+
await this.callbackClient.postEvent(createStateEvent({
|
|
353
|
+
namespace,
|
|
354
|
+
conversationId: action.conversationId,
|
|
355
|
+
thinking: false,
|
|
356
|
+
lastError: runError
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (result.meta.finalAssistantVisibleText) {
|
|
362
|
+
await delay(RUN_COMPLETION_SETTLE_MS);
|
|
363
|
+
}
|
|
364
|
+
if (adapterState.finishRun(action.conversationId)) {
|
|
365
|
+
await this.callbackClient.postEvent(createStateEvent({
|
|
366
|
+
namespace,
|
|
367
|
+
conversationId: action.conversationId,
|
|
368
|
+
thinking: false,
|
|
369
|
+
lastError: null
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
} catch (error2) {
|
|
373
|
+
const message = error2 instanceof Error ? error2.message : "OpenClaw embedded run failed";
|
|
374
|
+
if (adapterState.finishRun(action.conversationId)) {
|
|
375
|
+
await this.callbackClient.postEvent(createStateEvent({
|
|
376
|
+
namespace,
|
|
377
|
+
conversationId: action.conversationId,
|
|
378
|
+
thinking: false,
|
|
379
|
+
lastError: message
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
throw error2;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async approve(_action) {
|
|
386
|
+
throw new Error("Real OpenClaw approval bridge is not implemented yet");
|
|
387
|
+
}
|
|
388
|
+
async deny(_action) {
|
|
389
|
+
throw new Error("Real OpenClaw approval bridge is not implemented yet");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
166
393
|
// ../node_modules/.bun/openclaw@2026.4.11+63c81f13a188c8c3/node_modules/openclaw/dist/runtime-store-B1YLS6z5.js
|
|
167
394
|
function createPluginRuntimeStore(errorMessage) {
|
|
168
395
|
let runtime = null;
|
|
@@ -187,6 +414,156 @@ function createPluginRuntimeStore(errorMessage) {
|
|
|
187
414
|
// src/runtimeStore.ts
|
|
188
415
|
var runtimeStore = createPluginRuntimeStore("OpenClaw plugin runtime is not available outside native plugin registration");
|
|
189
416
|
|
|
417
|
+
// src/signing.ts
|
|
418
|
+
import { createHmac } from "node:crypto";
|
|
419
|
+
function signCallbackBody(timestamp, rawBody, secret) {
|
|
420
|
+
return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/hapiClient.ts
|
|
424
|
+
class HapiCallbackClient {
|
|
425
|
+
hapiBaseUrl;
|
|
426
|
+
sharedSecret;
|
|
427
|
+
constructor(hapiBaseUrl, sharedSecret) {
|
|
428
|
+
this.hapiBaseUrl = hapiBaseUrl;
|
|
429
|
+
this.sharedSecret = sharedSecret;
|
|
430
|
+
}
|
|
431
|
+
async postEvent(event) {
|
|
432
|
+
const rawBody = JSON.stringify(event);
|
|
433
|
+
const timestamp = Date.now();
|
|
434
|
+
const signature = signCallbackBody(timestamp, rawBody, this.sharedSecret);
|
|
435
|
+
const response = await fetch(new URL("/api/openclaw/channel/events", this.hapiBaseUrl).toString(), {
|
|
436
|
+
method: "POST",
|
|
437
|
+
headers: {
|
|
438
|
+
"content-type": "application/json",
|
|
439
|
+
"x-openclaw-timestamp": `${timestamp}`,
|
|
440
|
+
"x-openclaw-signature": signature
|
|
441
|
+
},
|
|
442
|
+
body: rawBody
|
|
443
|
+
});
|
|
444
|
+
if (!response.ok) {
|
|
445
|
+
const text = await response.text().catch(() => "");
|
|
446
|
+
const detail = text ? `: ${text}` : "";
|
|
447
|
+
throw new Error(`HAPI callback failed with HTTP ${response.status}${detail}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/transcriptEvents.ts
|
|
453
|
+
function isRecord2(value) {
|
|
454
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
455
|
+
}
|
|
456
|
+
function extractAssistantText(content) {
|
|
457
|
+
if (typeof content === "string") {
|
|
458
|
+
const normalized = stripReplyToCurrentPrefix(content);
|
|
459
|
+
return normalized.length > 0 ? normalized : null;
|
|
460
|
+
}
|
|
461
|
+
if (!Array.isArray(content)) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
const texts = content.flatMap((entry) => {
|
|
465
|
+
if (!isRecord2(entry)) {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
const block = entry;
|
|
469
|
+
if (block.type !== "text" || typeof block.text !== "string") {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
const normalized = stripReplyToCurrentPrefix(block.text);
|
|
473
|
+
return normalized.length > 0 ? [normalized] : [];
|
|
474
|
+
});
|
|
475
|
+
if (texts.length === 0) {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
return texts.join(`
|
|
479
|
+
|
|
480
|
+
`);
|
|
481
|
+
}
|
|
482
|
+
function normalizeAssistantTranscriptEvent(update) {
|
|
483
|
+
const parsed = parseHapiSessionKey(update.sessionKey);
|
|
484
|
+
if (!parsed || !isRecord2(update.message)) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
const message = update.message;
|
|
488
|
+
if (message.role !== "assistant") {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
const text = extractAssistantText(message.content);
|
|
492
|
+
if (!text) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
const externalMessageId = typeof update.messageId === "string" && update.messageId.length > 0 ? update.messageId : typeof message.responseId === "string" && message.responseId.length > 0 ? message.responseId : null;
|
|
496
|
+
if (!externalMessageId) {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
const createdAt = typeof message.timestamp === "number" && Number.isFinite(message.timestamp) ? message.timestamp : Date.now();
|
|
500
|
+
return {
|
|
501
|
+
type: "message",
|
|
502
|
+
eventId: `message:${externalMessageId}`,
|
|
503
|
+
occurredAt: createdAt,
|
|
504
|
+
namespace: parsed.namespace,
|
|
505
|
+
conversationId: update.sessionKey,
|
|
506
|
+
externalMessageId,
|
|
507
|
+
role: "assistant",
|
|
508
|
+
content: {
|
|
509
|
+
mode: "replace",
|
|
510
|
+
text
|
|
511
|
+
},
|
|
512
|
+
createdAt,
|
|
513
|
+
status: "completed"
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/transcriptBridge.ts
|
|
518
|
+
async function handleTranscriptUpdate(ctx, callbackClient, update) {
|
|
519
|
+
const event = normalizeAssistantTranscriptEvent(update);
|
|
520
|
+
if (!event) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (!adapterState.rememberTranscriptMessage(event.externalMessageId)) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
await callbackClient.postEvent(event);
|
|
527
|
+
if (adapterState.finishRun(event.conversationId)) {
|
|
528
|
+
await callbackClient.postEvent({
|
|
529
|
+
type: "state",
|
|
530
|
+
eventId: `${event.eventId}:state`,
|
|
531
|
+
occurredAt: Date.now(),
|
|
532
|
+
namespace: event.namespace,
|
|
533
|
+
conversationId: event.conversationId,
|
|
534
|
+
connected: true,
|
|
535
|
+
thinking: false,
|
|
536
|
+
lastError: null
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function createTranscriptBridgeService() {
|
|
541
|
+
let stopListening = null;
|
|
542
|
+
return {
|
|
543
|
+
id: `${OPENCLAW_PLUGIN_ID}:transcript-bridge`,
|
|
544
|
+
async start(ctx) {
|
|
545
|
+
const config = resolvePluginConfigFromOpenClawConfig(ctx.config);
|
|
546
|
+
if (!config) {
|
|
547
|
+
ctx.logger.warn(`Skipping ${OPENCLAW_PLUGIN_ID} transcript bridge because plugin config is unavailable`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const callbackClient = new HapiCallbackClient(config.hapiBaseUrl, config.sharedSecret);
|
|
551
|
+
const runtime = runtimeStore.getRuntime();
|
|
552
|
+
stopListening = runtime.events.onSessionTranscriptUpdate((update) => {
|
|
553
|
+
handleTranscriptUpdate(ctx, callbackClient, update).catch((error2) => {
|
|
554
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
555
|
+
ctx.logger.error(`Failed to bridge transcript update: ${message}`);
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
ctx.logger.info(`Started ${OPENCLAW_PLUGIN_ID} transcript-bridge service`);
|
|
559
|
+
},
|
|
560
|
+
async stop() {
|
|
561
|
+
stopListening?.();
|
|
562
|
+
stopListening = null;
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
190
567
|
// src/transcriptCapture.ts
|
|
191
568
|
import { appendFile, mkdir } from "node:fs/promises";
|
|
192
569
|
import { join } from "node:path";
|
|
@@ -238,165 +615,6 @@ function createTranscriptCaptureService() {
|
|
|
238
615
|
};
|
|
239
616
|
}
|
|
240
617
|
|
|
241
|
-
// src/signing.ts
|
|
242
|
-
import { createHmac } from "node:crypto";
|
|
243
|
-
function signCallbackBody(timestamp, rawBody, secret) {
|
|
244
|
-
return createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// src/hapiClient.ts
|
|
248
|
-
class HapiCallbackClient {
|
|
249
|
-
hapiBaseUrl;
|
|
250
|
-
sharedSecret;
|
|
251
|
-
constructor(hapiBaseUrl, sharedSecret) {
|
|
252
|
-
this.hapiBaseUrl = hapiBaseUrl;
|
|
253
|
-
this.sharedSecret = sharedSecret;
|
|
254
|
-
}
|
|
255
|
-
async postEvent(event) {
|
|
256
|
-
const rawBody = JSON.stringify(event);
|
|
257
|
-
const timestamp = Date.now();
|
|
258
|
-
const signature = signCallbackBody(timestamp, rawBody, this.sharedSecret);
|
|
259
|
-
const response = await fetch(new URL("/api/openclaw/channel/events", this.hapiBaseUrl).toString(), {
|
|
260
|
-
method: "POST",
|
|
261
|
-
headers: {
|
|
262
|
-
"content-type": "application/json",
|
|
263
|
-
"x-openclaw-timestamp": `${timestamp}`,
|
|
264
|
-
"x-openclaw-signature": signature
|
|
265
|
-
},
|
|
266
|
-
body: rawBody
|
|
267
|
-
});
|
|
268
|
-
if (!response.ok) {
|
|
269
|
-
const text = await response.text().catch(() => "");
|
|
270
|
-
const detail = text ? `: ${text}` : "";
|
|
271
|
-
throw new Error(`HAPI callback failed with HTTP ${response.status}${detail}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// src/openclawRuntime.ts
|
|
277
|
-
import { randomUUID } from "node:crypto";
|
|
278
|
-
|
|
279
|
-
class MockOpenClawRuntime {
|
|
280
|
-
namespace;
|
|
281
|
-
constructor(namespace) {
|
|
282
|
-
this.namespace = namespace;
|
|
283
|
-
}
|
|
284
|
-
ensureDefaultConversation(externalUserKey) {
|
|
285
|
-
return {
|
|
286
|
-
conversationId: `openclaw-plugin:${externalUserKey}`,
|
|
287
|
-
title: "OpenClaw"
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
run(action) {
|
|
291
|
-
const now = Date.now();
|
|
292
|
-
if (action.kind === "send-message") {
|
|
293
|
-
if (action.text.toLowerCase().includes("approval")) {
|
|
294
|
-
const requestId = `approval:${randomUUID()}`;
|
|
295
|
-
return [
|
|
296
|
-
{
|
|
297
|
-
type: "state",
|
|
298
|
-
eventId: randomUUID(),
|
|
299
|
-
occurredAt: now,
|
|
300
|
-
namespace: this.namespace,
|
|
301
|
-
conversationId: action.conversationId,
|
|
302
|
-
connected: true,
|
|
303
|
-
thinking: true,
|
|
304
|
-
lastError: null
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
type: "approval-request",
|
|
308
|
-
eventId: randomUUID(),
|
|
309
|
-
occurredAt: now + 1,
|
|
310
|
-
namespace: this.namespace,
|
|
311
|
-
conversationId: action.conversationId,
|
|
312
|
-
requestId,
|
|
313
|
-
title: "Approve OpenClaw action",
|
|
314
|
-
description: action.text,
|
|
315
|
-
createdAt: now + 1
|
|
316
|
-
},
|
|
317
|
-
{
|
|
318
|
-
type: "state",
|
|
319
|
-
eventId: randomUUID(),
|
|
320
|
-
occurredAt: now + 2,
|
|
321
|
-
namespace: this.namespace,
|
|
322
|
-
conversationId: action.conversationId,
|
|
323
|
-
connected: true,
|
|
324
|
-
thinking: false,
|
|
325
|
-
lastError: null
|
|
326
|
-
}
|
|
327
|
-
];
|
|
328
|
-
}
|
|
329
|
-
const externalMessageId = `assistant:${randomUUID()}`;
|
|
330
|
-
return [
|
|
331
|
-
{
|
|
332
|
-
type: "state",
|
|
333
|
-
eventId: randomUUID(),
|
|
334
|
-
occurredAt: now,
|
|
335
|
-
namespace: this.namespace,
|
|
336
|
-
conversationId: action.conversationId,
|
|
337
|
-
connected: true,
|
|
338
|
-
thinking: true,
|
|
339
|
-
lastError: null
|
|
340
|
-
},
|
|
341
|
-
{
|
|
342
|
-
type: "message",
|
|
343
|
-
eventId: randomUUID(),
|
|
344
|
-
occurredAt: now + 1,
|
|
345
|
-
namespace: this.namespace,
|
|
346
|
-
conversationId: action.conversationId,
|
|
347
|
-
externalMessageId,
|
|
348
|
-
role: "assistant",
|
|
349
|
-
content: { mode: "replace", text: "OpenClaw plugin echo: " },
|
|
350
|
-
createdAt: now + 1,
|
|
351
|
-
status: "streaming"
|
|
352
|
-
},
|
|
353
|
-
{
|
|
354
|
-
type: "message",
|
|
355
|
-
eventId: randomUUID(),
|
|
356
|
-
occurredAt: now + 2,
|
|
357
|
-
namespace: this.namespace,
|
|
358
|
-
conversationId: action.conversationId,
|
|
359
|
-
externalMessageId,
|
|
360
|
-
role: "assistant",
|
|
361
|
-
content: { mode: "append", delta: action.text.trim() || "(empty message)" },
|
|
362
|
-
createdAt: now + 2,
|
|
363
|
-
status: "completed"
|
|
364
|
-
},
|
|
365
|
-
{
|
|
366
|
-
type: "state",
|
|
367
|
-
eventId: randomUUID(),
|
|
368
|
-
occurredAt: now + 3,
|
|
369
|
-
namespace: this.namespace,
|
|
370
|
-
conversationId: action.conversationId,
|
|
371
|
-
connected: true,
|
|
372
|
-
thinking: false,
|
|
373
|
-
lastError: null
|
|
374
|
-
}
|
|
375
|
-
];
|
|
376
|
-
}
|
|
377
|
-
if (action.kind === "approve") {
|
|
378
|
-
return [{
|
|
379
|
-
type: "approval-resolved",
|
|
380
|
-
eventId: randomUUID(),
|
|
381
|
-
occurredAt: now,
|
|
382
|
-
namespace: this.namespace,
|
|
383
|
-
conversationId: action.conversationId,
|
|
384
|
-
requestId: action.requestId,
|
|
385
|
-
status: "approved"
|
|
386
|
-
}];
|
|
387
|
-
}
|
|
388
|
-
return [{
|
|
389
|
-
type: "approval-resolved",
|
|
390
|
-
eventId: randomUUID(),
|
|
391
|
-
occurredAt: now,
|
|
392
|
-
namespace: this.namespace,
|
|
393
|
-
conversationId: action.conversationId,
|
|
394
|
-
requestId: action.requestId,
|
|
395
|
-
status: "denied"
|
|
396
|
-
}];
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
618
|
// src/routes.ts
|
|
401
619
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
402
620
|
|
|
@@ -1927,6 +2145,12 @@ async function dispatchEvents(callbackClient, events) {
|
|
|
1927
2145
|
await callbackClient.postEvent(event);
|
|
1928
2146
|
}
|
|
1929
2147
|
}
|
|
2148
|
+
async function dispatchMaybeEvents(callbackClient, maybeEvents) {
|
|
2149
|
+
if (!maybeEvents || maybeEvents.length === 0) {
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
await dispatchEvents(callbackClient, maybeEvents);
|
|
2153
|
+
}
|
|
1930
2154
|
function createPluginApp(deps) {
|
|
1931
2155
|
const app = new Hono2;
|
|
1932
2156
|
const healthHandler = (c) => {
|
|
@@ -1956,7 +2180,7 @@ function createPluginApp(deps) {
|
|
|
1956
2180
|
if (!body?.externalUserKey) {
|
|
1957
2181
|
return c.json({ error: "Invalid body" }, 400);
|
|
1958
2182
|
}
|
|
1959
|
-
return c.json(deps.runtime.ensureDefaultConversation(body.externalUserKey));
|
|
2183
|
+
return c.json(await deps.runtime.ensureDefaultConversation(body.externalUserKey));
|
|
1960
2184
|
};
|
|
1961
2185
|
const sendMessageHandler = async (c) => {
|
|
1962
2186
|
const idempotencyKey = c.req.header("idempotency-key");
|
|
@@ -1971,6 +2195,12 @@ function createPluginApp(deps) {
|
|
|
1971
2195
|
if (!body?.conversationId || typeof body.text !== "string" || !body.localMessageId) {
|
|
1972
2196
|
return c.json({ error: "Invalid body" }, 400);
|
|
1973
2197
|
}
|
|
2198
|
+
if (deps.runtime.isConversationBusy?.(body.conversationId)) {
|
|
2199
|
+
return c.json({
|
|
2200
|
+
error: "Conversation already has an active OpenClaw run",
|
|
2201
|
+
retryAfterMs: 1000
|
|
2202
|
+
}, 409);
|
|
2203
|
+
}
|
|
1974
2204
|
const ack = {
|
|
1975
2205
|
accepted: true,
|
|
1976
2206
|
upstreamRequestId: `plugin-send:${randomUUID2()}`,
|
|
@@ -1979,16 +2209,25 @@ function createPluginApp(deps) {
|
|
|
1979
2209
|
};
|
|
1980
2210
|
deps.idempotencyCache.set(idempotencyKey, ack);
|
|
1981
2211
|
queueMicrotask(() => {
|
|
1982
|
-
|
|
2212
|
+
deps.runtime.sendMessage({
|
|
1983
2213
|
kind: "send-message",
|
|
1984
2214
|
conversationId: body.conversationId,
|
|
1985
2215
|
text: body.text,
|
|
1986
2216
|
localMessageId: body.localMessageId
|
|
1987
|
-
}))
|
|
2217
|
+
}).then(async (events) => {
|
|
2218
|
+
await dispatchMaybeEvents(deps.callbackClient, events);
|
|
2219
|
+
}).catch((error2) => {
|
|
2220
|
+
if (error2 instanceof ConversationBusyError) {
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
});
|
|
1988
2224
|
});
|
|
1989
2225
|
return c.json(ack);
|
|
1990
2226
|
};
|
|
1991
2227
|
const approveHandler = async (c) => {
|
|
2228
|
+
if (!deps.runtime.supportsApprovals) {
|
|
2229
|
+
return c.json({ error: "OpenClaw approval bridge is not implemented yet" }, 501);
|
|
2230
|
+
}
|
|
1992
2231
|
const idempotencyKey = c.req.header("idempotency-key");
|
|
1993
2232
|
if (!idempotencyKey) {
|
|
1994
2233
|
return c.json({ error: "Missing idempotency-key" }, 400);
|
|
@@ -2009,15 +2248,20 @@ function createPluginApp(deps) {
|
|
|
2009
2248
|
};
|
|
2010
2249
|
deps.idempotencyCache.set(idempotencyKey, ack);
|
|
2011
2250
|
queueMicrotask(() => {
|
|
2012
|
-
|
|
2251
|
+
deps.runtime.approve({
|
|
2013
2252
|
kind: "approve",
|
|
2014
2253
|
conversationId: body.conversationId,
|
|
2015
2254
|
requestId: c.req.param("requestId")
|
|
2016
|
-
}))
|
|
2255
|
+
}).then(async (events) => {
|
|
2256
|
+
await dispatchMaybeEvents(deps.callbackClient, events);
|
|
2257
|
+
}).catch(() => {});
|
|
2017
2258
|
});
|
|
2018
2259
|
return c.json(ack);
|
|
2019
2260
|
};
|
|
2020
2261
|
const denyHandler = async (c) => {
|
|
2262
|
+
if (!deps.runtime.supportsApprovals) {
|
|
2263
|
+
return c.json({ error: "OpenClaw approval bridge is not implemented yet" }, 501);
|
|
2264
|
+
}
|
|
2021
2265
|
const idempotencyKey = c.req.header("idempotency-key");
|
|
2022
2266
|
if (!idempotencyKey) {
|
|
2023
2267
|
return c.json({ error: "Missing idempotency-key" }, 400);
|
|
@@ -2038,11 +2282,13 @@ function createPluginApp(deps) {
|
|
|
2038
2282
|
};
|
|
2039
2283
|
deps.idempotencyCache.set(idempotencyKey, ack);
|
|
2040
2284
|
queueMicrotask(() => {
|
|
2041
|
-
|
|
2285
|
+
deps.runtime.deny({
|
|
2042
2286
|
kind: "deny",
|
|
2043
2287
|
conversationId: body.conversationId,
|
|
2044
2288
|
requestId: c.req.param("requestId")
|
|
2045
|
-
}))
|
|
2289
|
+
}).then(async (events) => {
|
|
2290
|
+
await dispatchMaybeEvents(deps.callbackClient, events);
|
|
2291
|
+
}).catch(() => {});
|
|
2046
2292
|
});
|
|
2047
2293
|
return c.json(ack);
|
|
2048
2294
|
};
|
|
@@ -2057,7 +2303,7 @@ function createPluginApp(deps) {
|
|
|
2057
2303
|
function registerPluginRoutes(api) {
|
|
2058
2304
|
const config = resolvePluginConfig(api.pluginConfig ?? {});
|
|
2059
2305
|
const callbackClient = new HapiCallbackClient(config.hapiBaseUrl, config.sharedSecret);
|
|
2060
|
-
const runtime = new
|
|
2306
|
+
const runtime = new RealOpenClawAdapter(config.namespace, api.runtime, callbackClient);
|
|
2061
2307
|
const app = createPluginApp({
|
|
2062
2308
|
sharedSecret: config.sharedSecret,
|
|
2063
2309
|
namespace: config.namespace,
|
|
@@ -2098,6 +2344,7 @@ var src_default = definePluginEntry({
|
|
|
2098
2344
|
}
|
|
2099
2345
|
runtimeStore.setRuntime(api.runtime);
|
|
2100
2346
|
registerPluginRoutes(api);
|
|
2347
|
+
api.registerService(createTranscriptBridgeService());
|
|
2101
2348
|
api.registerService(createTranscriptCaptureService());
|
|
2102
2349
|
}
|
|
2103
2350
|
});
|