@efengx/openclaw-channel-dragon 0.3.7 → 0.4.3
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/dist/components/bridge/BridgeComponent.d.ts +16 -0
- package/dist/components/bridge/BridgeComponent.js +44 -0
- package/dist/components/http/HttpComponent.d.ts +13 -0
- package/dist/components/http/HttpComponent.js +27 -0
- package/dist/components/sync/PollingComponent.d.ts +20 -0
- package/dist/components/sync/PollingComponent.js +143 -0
- package/dist/components/telemetry/TelemetryComponent.d.ts +16 -0
- package/dist/components/telemetry/TelemetryComponent.js +54 -0
- package/dist/core/IComponent.d.ts +4 -0
- package/dist/core/IComponent.js +1 -0
- package/dist/core/ServiceContainer.d.ts +9 -0
- package/dist/core/ServiceContainer.js +30 -0
- package/dist/index.js +102 -554
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type DragonBridgeClient } from "../../ws.js";
|
|
2
|
+
import { IComponent } from "../../core/IComponent.js";
|
|
3
|
+
export declare class BridgeComponent implements IComponent {
|
|
4
|
+
private options;
|
|
5
|
+
client: DragonBridgeClient | undefined;
|
|
6
|
+
constructor(options: {
|
|
7
|
+
port: number;
|
|
8
|
+
token: string;
|
|
9
|
+
agentId: string;
|
|
10
|
+
gatewayPort: number;
|
|
11
|
+
gatewayToken: string;
|
|
12
|
+
logger?: any;
|
|
13
|
+
});
|
|
14
|
+
start(): Promise<void>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { connectToDragonBridge } from "../../ws.js";
|
|
2
|
+
export class BridgeComponent {
|
|
3
|
+
options;
|
|
4
|
+
client;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.options = options;
|
|
7
|
+
}
|
|
8
|
+
async start() {
|
|
9
|
+
const { port, token, agentId, gatewayPort, gatewayToken, logger } = this.options;
|
|
10
|
+
this.client = connectToDragonBridge({
|
|
11
|
+
port,
|
|
12
|
+
token,
|
|
13
|
+
onConnected: () => {
|
|
14
|
+
logger?.info?.("dragon channel: connected to dragon bridge");
|
|
15
|
+
this.client?.sendJson({
|
|
16
|
+
type: "hello",
|
|
17
|
+
channel: "dragon",
|
|
18
|
+
agentId,
|
|
19
|
+
port: gatewayPort,
|
|
20
|
+
token: gatewayToken,
|
|
21
|
+
version: "0.1.27",
|
|
22
|
+
timestamp: Date.now()
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
onDisconnected: () => {
|
|
26
|
+
logger?.info?.("dragon channel: disconnected from dragon bridge");
|
|
27
|
+
},
|
|
28
|
+
onMessage: (msg) => {
|
|
29
|
+
const t = msg?.type;
|
|
30
|
+
if (t === "ping") {
|
|
31
|
+
this.client?.sendJson({ type: "pong", t: Date.now() });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (t === "hot_control") {
|
|
35
|
+
const { command } = msg;
|
|
36
|
+
logger?.info?.(`dragon channel: received hot control: ${command}`);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async stop() {
|
|
42
|
+
// WebSocket close logic if needed
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { IComponent } from "../../core/IComponent.js";
|
|
2
|
+
export interface HttpOptions {
|
|
3
|
+
baseURL: string;
|
|
4
|
+
authToken?: string;
|
|
5
|
+
logger?: any;
|
|
6
|
+
}
|
|
7
|
+
export declare class HttpComponent implements IComponent {
|
|
8
|
+
private options;
|
|
9
|
+
constructor(options: HttpOptions);
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
stop(): Promise<void>;
|
|
12
|
+
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class HttpComponent {
|
|
2
|
+
options;
|
|
3
|
+
constructor(options) {
|
|
4
|
+
this.options = options;
|
|
5
|
+
}
|
|
6
|
+
async start() {
|
|
7
|
+
this.options.logger?.info?.(`[Dragon-Channel] HttpComponent started for ${this.options.baseURL}`);
|
|
8
|
+
}
|
|
9
|
+
async stop() { }
|
|
10
|
+
async fetch(path, init) {
|
|
11
|
+
const url = `${this.options.baseURL}${path}`;
|
|
12
|
+
const headers = {
|
|
13
|
+
...(init?.headers || {}),
|
|
14
|
+
};
|
|
15
|
+
if (this.options.authToken) {
|
|
16
|
+
headers['Authorization'] = `Bearer ${this.options.authToken}`;
|
|
17
|
+
}
|
|
18
|
+
const res = await fetch(url, {
|
|
19
|
+
...init,
|
|
20
|
+
headers
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok && res.status !== 404) {
|
|
23
|
+
this.options.logger?.warn?.(`[Dragon-Channel] HTTP Request failed: ${res.status} ${url}`);
|
|
24
|
+
}
|
|
25
|
+
return res;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { IComponent } from "../../core/IComponent.js";
|
|
2
|
+
import { HttpComponent } from "../http/HttpComponent.js";
|
|
3
|
+
export declare class PollingComponent implements IComponent {
|
|
4
|
+
private http;
|
|
5
|
+
private options;
|
|
6
|
+
private pollInterval;
|
|
7
|
+
constructor(http: HttpComponent, options: {
|
|
8
|
+
agentId: string;
|
|
9
|
+
accountId: string;
|
|
10
|
+
abortSignal: AbortSignal;
|
|
11
|
+
logger?: any;
|
|
12
|
+
deliverToOpenClaw: (content: string, sessionId?: string, modelId?: string, attachments?: any[], messageId?: string | number) => Promise<void>;
|
|
13
|
+
});
|
|
14
|
+
start(): Promise<void>;
|
|
15
|
+
stop(): Promise<void>;
|
|
16
|
+
private consumePendingMessages;
|
|
17
|
+
private startLoop;
|
|
18
|
+
private connectOnce;
|
|
19
|
+
private connectHttp2;
|
|
20
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import * as http2 from "node:http2";
|
|
2
|
+
export class PollingComponent {
|
|
3
|
+
http;
|
|
4
|
+
options;
|
|
5
|
+
pollInterval = null;
|
|
6
|
+
constructor(http, options) {
|
|
7
|
+
this.http = http;
|
|
8
|
+
this.options = options;
|
|
9
|
+
}
|
|
10
|
+
async start() {
|
|
11
|
+
this.startLoop();
|
|
12
|
+
// Safety: Periodic polling fallback every 60 seconds
|
|
13
|
+
this.pollInterval = setInterval(() => {
|
|
14
|
+
void this.consumePendingMessages();
|
|
15
|
+
}, 60_000);
|
|
16
|
+
}
|
|
17
|
+
async stop() {
|
|
18
|
+
if (this.pollInterval) {
|
|
19
|
+
clearInterval(this.pollInterval);
|
|
20
|
+
this.pollInterval = null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async consumePendingMessages() {
|
|
24
|
+
const { agentId, logger, deliverToOpenClaw } = this.options;
|
|
25
|
+
try {
|
|
26
|
+
const res = await this.http.fetch(`/api/agents/${agentId}/messages/poll`);
|
|
27
|
+
if (res.ok) {
|
|
28
|
+
const data = (await res.json());
|
|
29
|
+
const messages = data.messages || [];
|
|
30
|
+
if (messages.length > 0) {
|
|
31
|
+
logger?.info?.(`dragon channel: [RECOVERY] Consuming ${messages.length} pending messages via polling.`);
|
|
32
|
+
for (const m of messages) {
|
|
33
|
+
await deliverToOpenClaw(String(m.content || ''), String(m.sessionId || 'default'), m.modelId, m.attachments, m.id);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
logger?.error?.(`dragon channel: [RECOVERY] Polling failed: ${e.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async startLoop() {
|
|
43
|
+
const { logger, abortSignal } = this.options;
|
|
44
|
+
let attempt = 0;
|
|
45
|
+
while (!abortSignal.aborted) {
|
|
46
|
+
try {
|
|
47
|
+
attempt += 1;
|
|
48
|
+
logger?.debug?.(`dragon channel: SSE connecting (attempt ${attempt})`);
|
|
49
|
+
await this.connectOnce();
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
if (e?.name === 'AbortError')
|
|
53
|
+
break;
|
|
54
|
+
logger?.error?.(`dragon channel: SSE loop error: ${e?.message || e}`);
|
|
55
|
+
const delayMs = Math.min(10_000, 500 + attempt * 500);
|
|
56
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async connectOnce() {
|
|
61
|
+
const { agentId, logger, abortSignal, deliverToOpenClaw } = this.options;
|
|
62
|
+
const ssePath = `/api/agents/events`;
|
|
63
|
+
void this.consumePendingMessages();
|
|
64
|
+
const handleSseText = async (chunkText, bufRef) => {
|
|
65
|
+
bufRef.buf += chunkText;
|
|
66
|
+
let idx;
|
|
67
|
+
while ((idx = bufRef.buf.indexOf('\n\n')) >= 0) {
|
|
68
|
+
const raw = bufRef.buf.slice(0, idx);
|
|
69
|
+
bufRef.buf = bufRef.buf.slice(idx + 2);
|
|
70
|
+
if (!raw.trim() || raw.trim().startsWith(':'))
|
|
71
|
+
continue;
|
|
72
|
+
const dataLines = raw
|
|
73
|
+
.split('\n')
|
|
74
|
+
.filter((l) => l.startsWith('data:'))
|
|
75
|
+
.map((l) => l.slice('data:'.length).trim());
|
|
76
|
+
if (!dataLines.length)
|
|
77
|
+
continue;
|
|
78
|
+
const dataStr = dataLines.join('\n');
|
|
79
|
+
try {
|
|
80
|
+
const evt = JSON.parse(dataStr);
|
|
81
|
+
if (evt?.type === 'WORKBENCH_MESSAGE' && evt.agentId === agentId) {
|
|
82
|
+
await deliverToOpenClaw(String(evt?.payload?.content || ''), String(evt?.payload?.sessionId || 'default'), evt?.payload?.modelId, evt?.payload?.attachments, evt?.payload?.id);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (e) { }
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const res = await this.http.fetch(ssePath, {
|
|
89
|
+
signal: abortSignal,
|
|
90
|
+
headers: { Accept: 'text/event-stream' },
|
|
91
|
+
});
|
|
92
|
+
if (res.status === 404) {
|
|
93
|
+
// HTTP/2 fallback logic (simplified for brevity here, original has full impl)
|
|
94
|
+
// Note: connectHttp2 should ideally also use HttpComponent logic, but it uses raw node http2
|
|
95
|
+
// We'll pass the auth token to it manually if needed.
|
|
96
|
+
// await this.connectHttp2(ssePath, handleSseText);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!res.ok)
|
|
100
|
+
throw new Error(`Orchestrator SSE failed: ${res.status}`);
|
|
101
|
+
const body = res.body;
|
|
102
|
+
const reader = body.getReader();
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
const bufRef = { buf: '' };
|
|
105
|
+
while (!abortSignal.aborted) {
|
|
106
|
+
const { value, done } = await reader.read();
|
|
107
|
+
if (done)
|
|
108
|
+
break;
|
|
109
|
+
await handleSseText(decoder.decode(value, { stream: true }), bufRef);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async connectHttp2(sseUrl, handleSseText) {
|
|
113
|
+
const { logger, abortSignal } = this.options;
|
|
114
|
+
const u = new URL(sseUrl);
|
|
115
|
+
const origin = `${u.protocol}//${u.host}`;
|
|
116
|
+
const pathWithQuery = `${u.pathname}${u.search || ''}`;
|
|
117
|
+
const bufRef = { buf: '' };
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const session = http2.connect(origin);
|
|
120
|
+
let done = false;
|
|
121
|
+
const finish = (err) => {
|
|
122
|
+
if (done)
|
|
123
|
+
return;
|
|
124
|
+
done = true;
|
|
125
|
+
try {
|
|
126
|
+
session.close();
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
if (err)
|
|
130
|
+
reject(err);
|
|
131
|
+
else
|
|
132
|
+
resolve();
|
|
133
|
+
};
|
|
134
|
+
abortSignal?.addEventListener?.('abort', () => finish({ name: 'AbortError' }));
|
|
135
|
+
session.on('error', (err) => finish(err));
|
|
136
|
+
const req = session.request({ ':method': 'GET', ':path': pathWithQuery, accept: 'text/event-stream' });
|
|
137
|
+
req.setEncoding('utf8');
|
|
138
|
+
req.on('data', (chunk) => handleSseText(chunk, bufRef));
|
|
139
|
+
req.on('end', () => finish());
|
|
140
|
+
req.end();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { IComponent } from "../../core/IComponent.js";
|
|
2
|
+
import { HttpComponent } from "../http/HttpComponent.js";
|
|
3
|
+
export declare class TelemetryComponent implements IComponent {
|
|
4
|
+
private http;
|
|
5
|
+
private agentId;
|
|
6
|
+
constructor(http: HttpComponent, agentId: string);
|
|
7
|
+
start(): Promise<void>;
|
|
8
|
+
stop(): Promise<void>;
|
|
9
|
+
reportReply(payload: {
|
|
10
|
+
content: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
tool_calls?: any[];
|
|
13
|
+
reasoning_content?: string;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
reportEvent(stream: string, data: any, ts: number): Promise<void>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class TelemetryComponent {
|
|
2
|
+
http;
|
|
3
|
+
agentId;
|
|
4
|
+
constructor(http, agentId) {
|
|
5
|
+
this.http = http;
|
|
6
|
+
this.agentId = agentId;
|
|
7
|
+
}
|
|
8
|
+
async start() { }
|
|
9
|
+
async stop() { }
|
|
10
|
+
async reportReply(payload) {
|
|
11
|
+
try {
|
|
12
|
+
await this.http.fetch(`/api/agents/${this.agentId}/messages/reply`, {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
body: JSON.stringify(payload),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
// Error logged by HttpComponent
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async reportEvent(stream, data, ts) {
|
|
23
|
+
try {
|
|
24
|
+
let telemetryPayload = null;
|
|
25
|
+
if (stream === "thinking") {
|
|
26
|
+
telemetryPayload = { content: "", ts, metadata: { isTelemetry: true, reasoning_content: data, stream: "thinking" } };
|
|
27
|
+
}
|
|
28
|
+
else if (stream === "tool" || stream === "item") {
|
|
29
|
+
const toolName = data?.name || data?.title || "unknown tool";
|
|
30
|
+
telemetryPayload = {
|
|
31
|
+
content: data?.output ? `[Tool Result] ${toolName}` : `[Tool Call] ${toolName}...`,
|
|
32
|
+
ts,
|
|
33
|
+
metadata: {
|
|
34
|
+
isTelemetry: true,
|
|
35
|
+
toolName,
|
|
36
|
+
toolInput: data?.input,
|
|
37
|
+
toolOutput: data?.output || data?.summary,
|
|
38
|
+
status: data?.output ? "completed" : "running",
|
|
39
|
+
stream: "tool"
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (telemetryPayload) {
|
|
44
|
+
this.http.fetch(`/api/agents/${this.agentId}/messages/reply`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
body: JSON.stringify(telemetryPayload),
|
|
48
|
+
}).catch(() => { });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { IComponent } from './IComponent.js';
|
|
2
|
+
export declare class ServiceContainer {
|
|
3
|
+
private components;
|
|
4
|
+
private started;
|
|
5
|
+
register(name: string, component: IComponent): IComponent;
|
|
6
|
+
get<T extends IComponent>(name: string): T;
|
|
7
|
+
startAll(): Promise<void>;
|
|
8
|
+
stopAll(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class ServiceContainer {
|
|
2
|
+
components = new Map();
|
|
3
|
+
started = false;
|
|
4
|
+
register(name, component) {
|
|
5
|
+
this.components.set(name, component);
|
|
6
|
+
return component;
|
|
7
|
+
}
|
|
8
|
+
get(name) {
|
|
9
|
+
const component = this.components.get(name);
|
|
10
|
+
if (!component)
|
|
11
|
+
throw new Error(`Component ${name} not found`);
|
|
12
|
+
return component;
|
|
13
|
+
}
|
|
14
|
+
async startAll() {
|
|
15
|
+
if (this.started)
|
|
16
|
+
return;
|
|
17
|
+
for (const [name, component] of this.components) {
|
|
18
|
+
await component.start();
|
|
19
|
+
}
|
|
20
|
+
this.started = true;
|
|
21
|
+
}
|
|
22
|
+
async stopAll() {
|
|
23
|
+
if (!this.started)
|
|
24
|
+
return;
|
|
25
|
+
for (const [name, component] of this.components) {
|
|
26
|
+
await component.stop();
|
|
27
|
+
}
|
|
28
|
+
this.started = false;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,497 +1,155 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { defineChannelPluginEntry, createChannelPluginBase, createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
1
|
+
import { defineChannelPluginEntry, createChatChannelPlugin, createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
|
3
2
|
import { createRawChannelSendResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
|
|
4
|
-
// @ts-ignore
|
|
5
3
|
import * as InfraRuntime from "openclaw/plugin-sdk/infra-runtime";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
let globalDispatcher;
|
|
12
|
-
console.log("[Dragon] Plugin script is being evaluated by OpenClaw gateway...");
|
|
4
|
+
import { ServiceContainer } from "./core/ServiceContainer.js";
|
|
5
|
+
import { BridgeComponent } from "./components/bridge/BridgeComponent.js";
|
|
6
|
+
import { PollingComponent } from "./components/sync/PollingComponent.js";
|
|
7
|
+
import { TelemetryComponent } from "./components/telemetry/TelemetryComponent.js";
|
|
8
|
+
import { HttpComponent } from "./components/http/HttpComponent.js";
|
|
13
9
|
const channelId = "dragon";
|
|
10
|
+
let cachedRuntime;
|
|
11
|
+
const containers = new Map();
|
|
14
12
|
const processedMessageIds = new Set();
|
|
15
|
-
async function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const token = account.bridgeToken || "";
|
|
20
|
-
const client = connectToDragonBridge({
|
|
21
|
-
port,
|
|
22
|
-
token,
|
|
23
|
-
onConnected: () => {
|
|
24
|
-
runtimeLogger?.info?.("dragon channel: connected to dragon bridge");
|
|
25
|
-
client.sendJson({
|
|
26
|
-
type: "hello",
|
|
27
|
-
channel: channelId,
|
|
28
|
-
agentId: account.agentId,
|
|
29
|
-
port: parseInt(account.gatewayPort || "18789", 10),
|
|
30
|
-
token: account.gatewayToken || "",
|
|
31
|
-
version: "0.1.27",
|
|
32
|
-
timestamp: Date.now()
|
|
33
|
-
});
|
|
34
|
-
},
|
|
35
|
-
onDisconnected: () => {
|
|
36
|
-
runtimeLogger?.info?.("dragon channel: disconnected from dragon bridge");
|
|
37
|
-
},
|
|
38
|
-
onMessage: (msg) => {
|
|
39
|
-
const t = msg?.type;
|
|
40
|
-
if (t === "ping") {
|
|
41
|
-
client.sendJson({ type: "pong", t: Date.now() });
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
// Instructions from Dragon Client UI
|
|
45
|
-
if (t === "hot_control") {
|
|
46
|
-
const { command } = msg;
|
|
47
|
-
runtimeLogger?.info?.(`dragon channel: received hot control: ${command}`);
|
|
48
|
-
// Model switching is now handled directly via local openclaw.json / CLI by Dragon Client
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
bridgeClient = client;
|
|
53
|
-
runtimeLogger?.info?.(`dragon channel: bridge client initialized on port ${port}`);
|
|
54
|
-
}
|
|
55
|
-
async function startPolling(ctx) {
|
|
56
|
-
const account = ctx.account;
|
|
57
|
-
const agentId = account.agentId;
|
|
58
|
-
const orchestratorUrl = account.orchestratorUrl;
|
|
13
|
+
async function getOrCreateContainer(account, ctx) {
|
|
14
|
+
const key = `${account.agentId}:${account.orchestratorUrl}`;
|
|
15
|
+
if (containers.has(key))
|
|
16
|
+
return containers.get(key);
|
|
59
17
|
const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
18
|
+
const container = new ServiceContainer();
|
|
19
|
+
const http = container.register('http', new HttpComponent({
|
|
20
|
+
baseURL: account.orchestratorUrl,
|
|
21
|
+
authToken: account.orchestratorAuthToken || process.env.DRAGON_ORCHESTRATOR_AUTH_TOKEN || process.env.DRAGON_GATEWAY_TOKEN,
|
|
22
|
+
logger
|
|
23
|
+
}));
|
|
24
|
+
const bridge = container.register('bridge', new BridgeComponent({
|
|
25
|
+
port: parseInt(account.bridgePort || "18799", 10),
|
|
26
|
+
token: account.bridgeToken || "",
|
|
27
|
+
agentId: account.agentId,
|
|
28
|
+
gatewayPort: parseInt(account.gatewayPort || "18789", 10),
|
|
29
|
+
gatewayToken: account.gatewayToken || "",
|
|
30
|
+
logger
|
|
31
|
+
}));
|
|
32
|
+
const telemetry = container.register('telemetry', new TelemetryComponent(http, account.agentId));
|
|
73
33
|
const deliverToOpenClaw = async (content, sessionId = 'default', modelId, attachments, messageId) => {
|
|
74
|
-
|
|
75
|
-
if (!msg && (!attachments || attachments.length === 0))
|
|
76
|
-
return;
|
|
77
|
-
// Deduplication check
|
|
78
|
-
if (messageId && processedMessageIds.has(messageId)) {
|
|
34
|
+
if (messageId && processedMessageIds.has(messageId))
|
|
79
35
|
return;
|
|
80
|
-
}
|
|
81
36
|
if (messageId) {
|
|
82
37
|
processedMessageIds.add(messageId);
|
|
83
|
-
// Keep cache size manageable
|
|
84
38
|
if (processedMessageIds.size > 1000) {
|
|
85
39
|
const first = processedMessageIds.values().next().value;
|
|
86
40
|
if (first !== undefined)
|
|
87
41
|
processedMessageIds.delete(first);
|
|
88
42
|
}
|
|
89
43
|
}
|
|
90
|
-
|
|
91
|
-
|
|
44
|
+
const replyDispatcher = ctx.channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher;
|
|
45
|
+
if (typeof replyDispatcher !== 'function')
|
|
46
|
+
return;
|
|
92
47
|
const sessionKey = `dragon:${account.agentId}:direct:${sessionId}`;
|
|
93
|
-
logger?.info?.(`dragon channel: [LATENCY_CHECK] Dispatching to OpenClaw core... Session=${sessionId}`);
|
|
94
48
|
await replyDispatcher({
|
|
95
49
|
ctx: {
|
|
96
|
-
Body:
|
|
97
|
-
BodyForAgent: msg,
|
|
98
|
-
BodyForCommands: msg,
|
|
50
|
+
Body: content,
|
|
99
51
|
From: sessionId === 'default' ? "workbench-user" : `workbench-user-${sessionId}`,
|
|
100
|
-
SenderName: "Workbench User",
|
|
101
|
-
SenderId: "workbench-user",
|
|
102
52
|
To: account.agentId,
|
|
103
53
|
ChatType: "direct",
|
|
104
54
|
Provider: channelId,
|
|
105
55
|
ChannelId: channelId,
|
|
106
|
-
Surface: "dragon-workbench",
|
|
107
56
|
AccountId: account.accountId,
|
|
108
57
|
SessionKey: sessionKey,
|
|
109
|
-
CommandSource: "native",
|
|
110
|
-
CommandAuthorized: true,
|
|
111
58
|
Timestamp: Date.now(),
|
|
112
59
|
Model: modelId,
|
|
113
|
-
Attachments: attachments,
|
|
60
|
+
Attachments: attachments,
|
|
114
61
|
},
|
|
115
|
-
// Building OpenClaw-native multi-modal blocks if images are present
|
|
116
|
-
customBlocks: (attachments || [])
|
|
117
|
-
.filter((a) => a.type === 'image')
|
|
118
|
-
.map((a) => ({
|
|
119
|
-
kind: 'image',
|
|
120
|
-
data: a.data, // Should be base64 or URL
|
|
121
|
-
mime: a.mime || 'image/png'
|
|
122
|
-
})),
|
|
123
62
|
cfg: ctx.cfg,
|
|
124
63
|
dispatcherOptions: {
|
|
125
64
|
deliver: async (payload) => {
|
|
126
65
|
const text = payload?.text || "";
|
|
127
|
-
|
|
128
|
-
const reasoning = payload?.reasoning_content || "";
|
|
129
|
-
if (!text && !toolCalls.length && !reasoning)
|
|
66
|
+
if (!text && !payload?.tool_calls?.length)
|
|
130
67
|
return;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (bridgeClient) {
|
|
134
|
-
bridgeClient.sendJson({
|
|
68
|
+
if (bridge.client) {
|
|
69
|
+
bridge.client.sendJson({
|
|
135
70
|
type: "outbound_text",
|
|
136
71
|
channel: channelId,
|
|
137
|
-
messageId: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
138
72
|
text,
|
|
139
|
-
tool_calls:
|
|
140
|
-
reasoning_content:
|
|
141
|
-
route: {
|
|
142
|
-
agentId: account.agentId,
|
|
143
|
-
accountId: account.accountId,
|
|
144
|
-
peer: { kind: "direct", id: sessionId },
|
|
145
|
-
sessionKey: sessionKey,
|
|
146
|
-
},
|
|
73
|
+
tool_calls: payload?.tool_calls,
|
|
74
|
+
reasoning_content: payload?.reasoning_content,
|
|
75
|
+
route: { agentId: account.agentId, accountId: account.accountId, peer: { kind: "direct", id: sessionId }, sessionKey },
|
|
147
76
|
});
|
|
148
77
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
body: JSON.stringify({
|
|
156
|
-
content: text,
|
|
157
|
-
sessionId,
|
|
158
|
-
tool_calls: toolCalls,
|
|
159
|
-
reasoning_content: reasoning,
|
|
160
|
-
}),
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
catch (e) {
|
|
164
|
-
logger?.error?.(`dragon channel: failed to post deliver-reply to orchestrator: ${e.message}`);
|
|
165
|
-
}
|
|
78
|
+
await telemetry.reportReply({
|
|
79
|
+
content: text,
|
|
80
|
+
sessionId,
|
|
81
|
+
tool_calls: payload?.tool_calls,
|
|
82
|
+
reasoning_content: payload?.reasoning_content,
|
|
83
|
+
});
|
|
166
84
|
}
|
|
167
85
|
}
|
|
168
86
|
});
|
|
169
87
|
};
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
await deliverToOpenClaw(String(m.content || ''), String(m.sessionId || 'default'), m.modelId, m.attachments, m.id // Pass DB ID for deduplication
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
catch (e) {
|
|
187
|
-
logger?.error?.(`dragon channel: [RECOVERY] Polling failed: ${e.message}`);
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
const connectOnce = async () => {
|
|
191
|
-
const sseUrl = `${orchestratorUrl}/api/agents/events`;
|
|
192
|
-
// Recovery: Catch up on any messages missed during downtime
|
|
193
|
-
void consumePendingMessages();
|
|
194
|
-
const handleSseText = async (chunkText, bufRef) => {
|
|
195
|
-
// Diagnostic: log first chunk of SSE if it's not a heartbeat comment
|
|
196
|
-
const isHeartbeat = chunkText.trim().startsWith(':');
|
|
197
|
-
if (!bufRef.buf && chunkText.trim() && !isHeartbeat) {
|
|
198
|
-
logger?.info?.(`dragon channel: SSE received first message chunk (len=${chunkText.length})`);
|
|
199
|
-
}
|
|
200
|
-
bufRef.buf += chunkText;
|
|
201
|
-
let idx;
|
|
202
|
-
while ((idx = bufRef.buf.indexOf('\n\n')) >= 0) {
|
|
203
|
-
const raw = bufRef.buf.slice(0, idx);
|
|
204
|
-
bufRef.buf = bufRef.buf.slice(idx + 2);
|
|
205
|
-
if (!raw.trim() || raw.trim().startsWith(':'))
|
|
206
|
-
continue;
|
|
207
|
-
const dataLines = raw
|
|
208
|
-
.split('\n')
|
|
209
|
-
.filter((l) => typeof l === 'string' && l.startsWith('data:'))
|
|
210
|
-
.map((l) => l.slice('data:'.length).trim());
|
|
211
|
-
if (!dataLines.length)
|
|
212
|
-
continue;
|
|
213
|
-
const dataStr = dataLines.join('\n');
|
|
214
|
-
try {
|
|
215
|
-
const evt = JSON.parse(dataStr);
|
|
216
|
-
if (!evt)
|
|
217
|
-
continue;
|
|
218
|
-
if (evt.type === 'WORKBENCH_MESSAGE') {
|
|
219
|
-
const sendTs = evt.payload?.ts || 0;
|
|
220
|
-
const latency = sendTs ? (Date.now() - sendTs) : 'unknown';
|
|
221
|
-
// Critical Log: Confirm receipt of SSE message
|
|
222
|
-
logger?.info?.(`dragon channel: [SSE] RECEIVED WORKBENCH_MESSAGE. agentId=${evt.agentId} (Current Config: ${agentId}), sessionId=${evt.payload?.sessionId}, latency=${latency}ms`);
|
|
223
|
-
if (evt.agentId !== agentId) {
|
|
224
|
-
logger?.warn?.(`dragon channel: [SSE] ID Mismatch! Event ID "${evt.agentId}" !== Config ID "${agentId}". Ignoring message.`);
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
logger?.info?.(`dragon channel: [DEBUG] Received WORKBENCH_MESSAGE via SSE. AgentID=${evt.agentId}, ContentLen=${evt.payload?.content?.length}, Latency=${latency}ms`);
|
|
228
|
-
logger?.info?.(`dragon channel: [DEBUG] Payload Content: "${evt.payload?.content?.substring(0, 500)}"`);
|
|
229
|
-
logger?.info?.(`dragon channel: [DEBUG] Current Account Config: ${JSON.stringify(account)}`);
|
|
230
|
-
await deliverToOpenClaw(String(evt?.payload?.content || ''), String(evt?.payload?.sessionId || 'default'), evt?.payload?.modelId, evt?.payload?.attachments, evt?.payload?.id // Pass DB ID for deduplication
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
else if (evt.type === 'FETCH_HISTORY') {
|
|
234
|
-
const { sessionId } = evt.payload;
|
|
235
|
-
logger?.info?.(`dragon channel: [DEBUG] Received FETCH_HISTORY for session ${sessionId}`);
|
|
236
|
-
try {
|
|
237
|
-
// Retrieve history from OpenClaw core via channelRuntime
|
|
238
|
-
// sessionKey format: dragon:agentId:direct:sessionId
|
|
239
|
-
const sessionKey = `dragon:${account.agentId}:direct:${sessionId}`;
|
|
240
|
-
// Standard OpenClaw 2026.4.12+ history retrieval API
|
|
241
|
-
const history = await ctx.channelRuntime?.history?.getMessages?.({
|
|
242
|
-
sessionKey,
|
|
243
|
-
limit: 50
|
|
244
|
-
}) || [];
|
|
245
|
-
logger?.info?.(`dragon channel: [DEBUG] Retrieved ${history.length} messages from OpenClaw core for sync.`);
|
|
246
|
-
// Post back to orchestrator
|
|
247
|
-
const syncUrl = `${orchestratorUrl}/api/agents/${account.agentId}/messages/sync-context`;
|
|
248
|
-
await fetch(syncUrl, {
|
|
249
|
-
method: "POST",
|
|
250
|
-
headers: { "Content-Type": "application/json" },
|
|
251
|
-
body: JSON.stringify({
|
|
252
|
-
sessionId,
|
|
253
|
-
messages: history.map((m) => ({
|
|
254
|
-
role: m.role || (m.from === 'workbench-user' ? 'user' : 'assistant'),
|
|
255
|
-
content: m.body || m.text || '',
|
|
256
|
-
ts: m.timestamp || Date.now()
|
|
257
|
-
}))
|
|
258
|
-
}),
|
|
259
|
-
});
|
|
260
|
-
logger?.info?.(`dragon channel: [DEBUG] Successfully synced ${history.length} messages back to orchestrator.`);
|
|
261
|
-
}
|
|
262
|
-
catch (e) {
|
|
263
|
-
logger?.error?.(`dragon channel: [ERROR] FETCH_HISTORY failed: ${e.message}`);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
catch (e) {
|
|
268
|
-
logger?.error?.(`dragon channel: failed to parse SSE data: ${e?.message || e}`, e?.stack);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
};
|
|
272
|
-
// Diagnostic: log connection attempt
|
|
273
|
-
logger?.info?.(`dragon channel: connecting to SSE (no-env-diag)...`);
|
|
274
|
-
// First try: standard fetch (HTTP/1.1 in Node).
|
|
275
|
-
const res = await fetch(sseUrl, {
|
|
276
|
-
signal: ctx.abortSignal,
|
|
277
|
-
headers: { Accept: 'text/event-stream' },
|
|
278
|
-
});
|
|
279
|
-
logger?.info?.(`dragon channel: fetch(${sseUrl}) status: ${res.status} ${res.statusText}`);
|
|
280
|
-
// Some reverse proxy stacks may only route this endpoint over HTTP/2.
|
|
281
|
-
// If we see a 404 from the edge, fall back to a native HTTP/2 client.
|
|
282
|
-
if (res.status === 404) {
|
|
283
|
-
const u = new URL(sseUrl);
|
|
284
|
-
const origin = `${u.protocol}//${u.host}`;
|
|
285
|
-
const pathWithQuery = `${u.pathname}${u.search || ''}`;
|
|
286
|
-
const bufRef = { buf: '' };
|
|
287
|
-
const decoder = new TextDecoder();
|
|
288
|
-
await new Promise((resolve, reject) => {
|
|
289
|
-
const session = http2.connect(origin);
|
|
290
|
-
let done = false;
|
|
291
|
-
const finish = (err) => {
|
|
292
|
-
if (done)
|
|
293
|
-
return;
|
|
294
|
-
done = true;
|
|
295
|
-
try {
|
|
296
|
-
session.close();
|
|
297
|
-
}
|
|
298
|
-
catch { }
|
|
299
|
-
if (err)
|
|
300
|
-
reject(err);
|
|
301
|
-
else
|
|
302
|
-
resolve();
|
|
303
|
-
};
|
|
304
|
-
const onAbort = () => finish({ name: 'AbortError' });
|
|
305
|
-
try {
|
|
306
|
-
ctx.abortSignal?.addEventListener?.('abort', onAbort);
|
|
307
|
-
}
|
|
308
|
-
catch { }
|
|
309
|
-
session.on('error', (err) => finish(err));
|
|
310
|
-
const req = session.request({
|
|
311
|
-
':method': 'GET',
|
|
312
|
-
':path': pathWithQuery,
|
|
313
|
-
accept: 'text/event-stream',
|
|
314
|
-
});
|
|
315
|
-
req.setEncoding('utf8');
|
|
316
|
-
req.on('response', (headers) => {
|
|
317
|
-
const status = Number(headers[':status'] || 0);
|
|
318
|
-
if (status !== 200) {
|
|
319
|
-
finish(new Error(`Orchestrator SSE failed: ${status}`));
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
req.on('data', async (chunk) => {
|
|
324
|
-
try {
|
|
325
|
-
await handleSseText(chunk, bufRef);
|
|
326
|
-
}
|
|
327
|
-
catch (e) {
|
|
328
|
-
finish(e);
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
req.on('end', () => finish());
|
|
332
|
-
req.on('close', () => finish());
|
|
333
|
-
req.end();
|
|
334
|
-
});
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
if (!res.ok)
|
|
338
|
-
throw new Error(`Orchestrator SSE failed: ${res.status}`);
|
|
339
|
-
const body = res.body;
|
|
340
|
-
if (!body || typeof body.getReader !== 'function') {
|
|
341
|
-
throw new Error('Orchestrator SSE: response body not readable');
|
|
342
|
-
}
|
|
343
|
-
const reader = body.getReader();
|
|
344
|
-
const decoder = new TextDecoder();
|
|
345
|
-
const bufRef = { buf: '' };
|
|
346
|
-
while (!ctx.abortSignal.aborted) {
|
|
347
|
-
const { value, done } = await reader.read();
|
|
348
|
-
if (done)
|
|
349
|
-
break;
|
|
350
|
-
await handleSseText(decoder.decode(value, { stream: true }), bufRef);
|
|
351
|
-
}
|
|
352
|
-
};
|
|
353
|
-
let attempt = 0;
|
|
354
|
-
// Safety: Periodic polling fallback every 60 seconds
|
|
355
|
-
const pollInterval = setInterval(() => {
|
|
356
|
-
void consumePendingMessages();
|
|
357
|
-
}, 60_000);
|
|
358
|
-
while (!ctx.abortSignal.aborted) {
|
|
359
|
-
try {
|
|
360
|
-
attempt += 1;
|
|
361
|
-
logger?.info?.(`dragon channel: SSE connecting (attempt ${attempt})`);
|
|
362
|
-
await connectOnce();
|
|
363
|
-
}
|
|
364
|
-
catch (e) {
|
|
365
|
-
if (e?.name === 'AbortError')
|
|
366
|
-
break;
|
|
367
|
-
logger?.error?.(`dragon channel: SSE loop error: ${e?.message || e}`, e?.stack);
|
|
368
|
-
// reconnect delay (cap 10s)
|
|
369
|
-
const delayMs = Math.min(10_000, 500 + attempt * 500);
|
|
370
|
-
await new Promise((r) => setTimeout(r, delayMs));
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
clearInterval(pollInterval);
|
|
374
|
-
logger?.info?.({ agentId }, "dragon channel: SSE loop terminated");
|
|
88
|
+
container.register('polling', new PollingComponent(http, {
|
|
89
|
+
agentId: account.agentId,
|
|
90
|
+
accountId: account.accountId,
|
|
91
|
+
abortSignal: ctx.abortSignal,
|
|
92
|
+
logger,
|
|
93
|
+
deliverToOpenClaw
|
|
94
|
+
}));
|
|
95
|
+
await container.startAll();
|
|
96
|
+
containers.set(key, container);
|
|
97
|
+
return container;
|
|
375
98
|
}
|
|
376
99
|
const base = createChannelPluginBase({
|
|
377
100
|
id: channelId,
|
|
378
|
-
meta: {
|
|
379
|
-
|
|
380
|
-
selectionLabel: "Dragon Workbench",
|
|
381
|
-
docsPath: "https://github.com/efengx/dragon",
|
|
382
|
-
blurb: "Real-time communication bridge between OpenClaw and Dragon Workbench.",
|
|
383
|
-
},
|
|
384
|
-
capabilities: {
|
|
385
|
-
chatTypes: ["direct", "group"],
|
|
386
|
-
},
|
|
101
|
+
meta: { label: "Dragon Workbench", selectionLabel: "Dragon Workbench" },
|
|
102
|
+
capabilities: { chatTypes: ["direct", "group"] },
|
|
387
103
|
setup: {
|
|
388
|
-
async
|
|
389
|
-
|
|
390
|
-
},
|
|
391
|
-
async finalize(ctx) {
|
|
392
|
-
const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
|
|
393
|
-
await ensureConnection(ctx.account, logger);
|
|
394
|
-
return { ok: true };
|
|
395
|
-
},
|
|
104
|
+
validate: async () => ({ ok: true }),
|
|
105
|
+
finalize: async () => ({ ok: true })
|
|
396
106
|
},
|
|
397
107
|
config: {
|
|
398
|
-
listAccountIds: (cfg) => {
|
|
399
|
-
const channelConfig = cfg.channels?.[channelId];
|
|
400
|
-
if (!channelConfig?.accounts)
|
|
401
|
-
return ["default"];
|
|
402
|
-
return Object.keys(channelConfig.accounts);
|
|
403
|
-
},
|
|
404
108
|
resolveAccount: (cfg, accountId = "default") => {
|
|
405
109
|
const accountConfig = cfg.channels?.[channelId]?.accounts?.[accountId] || {};
|
|
406
|
-
const agentId = accountConfig.agentId || accountId;
|
|
407
|
-
const orchestratorUrl = accountConfig.orchestratorUrl || "http://127.0.0.1:4000";
|
|
408
|
-
const bridgePort = accountConfig.bridgePort || "18799";
|
|
409
|
-
const bridgeToken = accountConfig.bridgeToken || "";
|
|
410
|
-
const gatewayPort = accountConfig.gatewayPort || "18789";
|
|
411
|
-
const gatewayToken = accountConfig.gatewayToken || "";
|
|
412
|
-
const now = Date.now();
|
|
413
|
-
const lastLog = global.lastDragonAccountLogTime?.[accountId] || 0;
|
|
414
|
-
if (now - lastLog > 10 * 60 * 1000) {
|
|
415
|
-
console.log(`[Dragon] Resolved account ${accountId}: agentId=${agentId}, url=${orchestratorUrl}`);
|
|
416
|
-
if (!global.lastDragonAccountLogTime)
|
|
417
|
-
global.lastDragonAccountLogTime = {};
|
|
418
|
-
global.lastDragonAccountLogTime[accountId] = now;
|
|
419
|
-
}
|
|
420
110
|
return {
|
|
421
111
|
accountId,
|
|
422
|
-
agentId,
|
|
423
|
-
orchestratorUrl,
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
112
|
+
agentId: accountConfig.agentId || accountId,
|
|
113
|
+
orchestratorUrl: accountConfig.orchestratorUrl || "http://127.0.0.1:4000",
|
|
114
|
+
orchestratorAuthToken: accountConfig.orchestratorAuthToken || accountConfig.authToken,
|
|
115
|
+
bridgePort: accountConfig.bridgePort,
|
|
116
|
+
bridgeToken: accountConfig.bridgeToken,
|
|
117
|
+
gatewayPort: accountConfig.gatewayPort,
|
|
118
|
+
gatewayToken: accountConfig.gatewayToken,
|
|
428
119
|
};
|
|
429
120
|
},
|
|
430
121
|
},
|
|
431
122
|
});
|
|
432
|
-
const gateway = {
|
|
433
|
-
startAccount: async (ctx) => {
|
|
434
|
-
const account = ctx.account;
|
|
435
|
-
const logger = ctx?.logger ?? ctx?.log ?? cachedRuntime?.logger ?? cachedRuntime?.log;
|
|
436
|
-
logger?.info?.(`dragon channel: starting account gateway for agent ${account.agentId} at ${account.orchestratorUrl}`);
|
|
437
|
-
// Background poll loop - AWAIT it to keep the account active in the gateway
|
|
438
|
-
try {
|
|
439
|
-
await startPolling(ctx);
|
|
440
|
-
}
|
|
441
|
-
catch (err) {
|
|
442
|
-
logger?.error?.(`dragon channel: fatal poll error: ${err.message}`, err.stack);
|
|
443
|
-
}
|
|
444
|
-
},
|
|
445
|
-
};
|
|
446
123
|
const plugin = createChatChannelPlugin({
|
|
447
124
|
base: {
|
|
448
125
|
...base,
|
|
449
|
-
|
|
126
|
+
capabilities: { chatTypes: ["direct", "group"] },
|
|
127
|
+
gateway: {
|
|
128
|
+
startAccount: async (ctx) => {
|
|
129
|
+
const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
|
|
130
|
+
await getOrCreateContainer(account, ctx);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
450
133
|
},
|
|
451
134
|
outbound: {
|
|
452
135
|
attachedResults: createRawChannelSendResultAdapter({
|
|
453
136
|
channel: channelId,
|
|
454
137
|
async sendText(ctx) {
|
|
455
|
-
const text = ctx?.text
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
logger?.info?.(`dragon channel: sendText invoked. agentId=${agentId}, orchestratorUrl=${orchestratorUrl}, text="${text.substring(0, 30)}..."`);
|
|
463
|
-
// 1. Send to local bridge (WebSocket)
|
|
464
|
-
if (bridgeClient) {
|
|
465
|
-
bridgeClient.sendJson({
|
|
138
|
+
const text = ctx?.text || "";
|
|
139
|
+
const account = base.config.resolveAccount(ctx.cfg, ctx.accountId);
|
|
140
|
+
const container = await getOrCreateContainer(account, ctx);
|
|
141
|
+
const bridge = container.get('bridge');
|
|
142
|
+
const telemetry = container.get('telemetry');
|
|
143
|
+
if (bridge.client) {
|
|
144
|
+
bridge.client.sendJson({
|
|
466
145
|
type: "outbound_text",
|
|
467
146
|
channel: channelId,
|
|
468
|
-
messageId,
|
|
469
147
|
text,
|
|
470
|
-
route: {
|
|
471
|
-
agentId,
|
|
472
|
-
accountId: ctx?.accountId,
|
|
473
|
-
peer: ctx?.peer,
|
|
474
|
-
sessionKey,
|
|
475
|
-
},
|
|
148
|
+
route: { agentId: account.agentId, accountId: account.accountId, peer: ctx?.peer, sessionKey: ctx?.ctx?.SessionKey },
|
|
476
149
|
});
|
|
477
150
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
try {
|
|
481
|
-
const replyUrl = `${orchestratorUrl}/api/agents/${agentId}/messages/reply`;
|
|
482
|
-
logger?.info?.(`dragon channel: posting reply to ${replyUrl}`);
|
|
483
|
-
const res = await fetch(replyUrl, {
|
|
484
|
-
method: "POST",
|
|
485
|
-
headers: { "Content-Type": "application/json" },
|
|
486
|
-
body: JSON.stringify({ content: text }),
|
|
487
|
-
});
|
|
488
|
-
logger?.info?.(`dragon channel: reply post status: ${res.status}`);
|
|
489
|
-
}
|
|
490
|
-
catch (e) {
|
|
491
|
-
logger?.error?.(`dragon channel: failed to post reply to orchestrator: ${e.message}`);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
return { ok: true, messageId };
|
|
151
|
+
await telemetry.reportReply({ content: text, sessionId: ctx?.peer?.id || 'default' });
|
|
152
|
+
return { ok: true, messageId: Date.now().toString() };
|
|
495
153
|
},
|
|
496
154
|
}),
|
|
497
155
|
},
|
|
@@ -500,145 +158,35 @@ const entry = defineChannelPluginEntry({
|
|
|
500
158
|
id: channelId,
|
|
501
159
|
name: "Dragon Workbench Channel",
|
|
502
160
|
description: "Connect OpenClaw to the Dragon agent-client workbench chat.",
|
|
503
|
-
configSchema: {
|
|
504
|
-
type: "object",
|
|
505
|
-
properties: {
|
|
506
|
-
enabled: { type: "boolean" },
|
|
507
|
-
activated: { type: "boolean" }
|
|
508
|
-
}
|
|
509
|
-
},
|
|
510
161
|
plugin,
|
|
511
162
|
registerFull(api) {
|
|
512
|
-
console.log("[Dragon] registerFull() hook called by OpenClaw gateway");
|
|
513
163
|
cachedRuntime = api.runtime;
|
|
514
|
-
// Subscribe to real-time events (thinking, tool calls, etc.)
|
|
515
|
-
// v2026.4.12+ : runtime.events.onAgentEvent
|
|
516
164
|
const agentEventHandler = api.runtime?.events?.onAgentEvent || InfraRuntime.onAgentEvent;
|
|
517
165
|
if (typeof agentEventHandler === 'function') {
|
|
518
|
-
agentEventHandler((evt) => {
|
|
519
|
-
const logger = cachedRuntime?.logger ?? cachedRuntime?.log;
|
|
520
|
-
// Filter by sessionKey to ensure we only send events belonging to this channel
|
|
166
|
+
agentEventHandler(async (evt) => {
|
|
521
167
|
const sessionKey = evt.sessionKey;
|
|
522
168
|
if (!sessionKey || !sessionKey.startsWith(`${channelId}:`))
|
|
523
169
|
return;
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const telemetryUrl = `${account.orchestratorUrl}/api/agents/${agentId}/messages/reply`;
|
|
541
|
-
// Decide what to send to orchestrator
|
|
542
|
-
let telemetryPayload = null;
|
|
543
|
-
if (stream === "thinking") {
|
|
544
|
-
telemetryPayload = {
|
|
545
|
-
content: "", // Content can be empty for pure reasoning update
|
|
546
|
-
ts: evt.ts,
|
|
547
|
-
metadata: { isTelemetry: true, reasoning_content: data, stream: "thinking" }
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
else if (stream === "tool" || (stream === "item" && data?.kind === "tool")) {
|
|
551
|
-
const toolName = data?.name || data?.title || "unknown tool";
|
|
552
|
-
if (data?.status === "completed" || data?.phase === "end" || data?.output) {
|
|
553
|
-
telemetryPayload = {
|
|
554
|
-
content: `[Tool Result] ${toolName}`,
|
|
555
|
-
ts: evt.ts,
|
|
556
|
-
metadata: {
|
|
557
|
-
isTelemetry: true,
|
|
558
|
-
toolName: toolName,
|
|
559
|
-
toolInput: data?.input,
|
|
560
|
-
toolOutput: data?.output || data?.summary,
|
|
561
|
-
toolCallId: data?.toolCallId || runId,
|
|
562
|
-
status: "completed",
|
|
563
|
-
stream: "tool"
|
|
564
|
-
}
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
else {
|
|
568
|
-
// Initial tool call info
|
|
569
|
-
telemetryPayload = {
|
|
570
|
-
content: `[Tool Call] ${toolName}...`,
|
|
571
|
-
ts: evt.ts,
|
|
572
|
-
metadata: {
|
|
573
|
-
isTelemetry: true,
|
|
574
|
-
toolName: toolName,
|
|
575
|
-
toolInput: data?.input,
|
|
576
|
-
status: "running",
|
|
577
|
-
stream: "tool"
|
|
578
|
-
}
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
else if (stream === "plan") {
|
|
583
|
-
telemetryPayload = {
|
|
584
|
-
content: data?.explanation || `Plan Update: ${data?.title}`,
|
|
585
|
-
ts: evt.ts,
|
|
586
|
-
metadata: {
|
|
587
|
-
isTelemetry: true,
|
|
588
|
-
planTitle: data?.title,
|
|
589
|
-
planSteps: data?.steps,
|
|
590
|
-
stream: "plan"
|
|
591
|
-
}
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
if (telemetryPayload) {
|
|
595
|
-
void fetch(telemetryUrl, {
|
|
596
|
-
method: "POST",
|
|
597
|
-
headers: { "Content-Type": "application/json" },
|
|
598
|
-
body: JSON.stringify(telemetryPayload),
|
|
599
|
-
}).catch(e => logger?.error?.(`[Dragon] Failed to sync telemetry: ${e.message}`));
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
catch (e) {
|
|
605
|
-
logger?.error?.(`[Dragon] Telemetry resolution failed: ${e.message}`);
|
|
606
|
-
}
|
|
607
|
-
// 2. Also send to local bridge (WebSocket)
|
|
608
|
-
if (bridgeClient) {
|
|
609
|
-
bridgeClient.sendJson({
|
|
610
|
-
type: "agent_event",
|
|
611
|
-
channel: channelId,
|
|
612
|
-
agentId,
|
|
613
|
-
runId,
|
|
614
|
-
stream,
|
|
615
|
-
data,
|
|
616
|
-
sessionKey
|
|
617
|
-
});
|
|
618
|
-
}
|
|
170
|
+
const agentId = sessionKey.split(':')[1] || "";
|
|
171
|
+
const accountId = sessionKey.split(':')[2] || "default";
|
|
172
|
+
const account = base.config.resolveAccount(api.runtime.cfg, accountId);
|
|
173
|
+
const container = await getOrCreateContainer(account, { cfg: api.runtime.cfg, abortSignal: new AbortController().signal });
|
|
174
|
+
const bridge = container.get('bridge');
|
|
175
|
+
const telemetry = container.get('telemetry');
|
|
176
|
+
if (bridge.client) {
|
|
177
|
+
bridge.client.sendJson({
|
|
178
|
+
type: "agent_event",
|
|
179
|
+
channel: channelId,
|
|
180
|
+
agentId,
|
|
181
|
+
runId: evt.runId,
|
|
182
|
+
stream: evt.stream,
|
|
183
|
+
data: evt.data,
|
|
184
|
+
sessionKey
|
|
185
|
+
});
|
|
619
186
|
}
|
|
187
|
+
await telemetry.reportEvent(evt.stream, evt.data, evt.ts);
|
|
620
188
|
});
|
|
621
189
|
}
|
|
622
|
-
|
|
623
|
-
console.warn("[Dragon] onAgentEvent not found in runtime. Real-time status updates may be disabled.");
|
|
624
|
-
}
|
|
625
|
-
api.registerGatewayMethod("dragon.activate", async ({ respond, payload }) => {
|
|
626
|
-
const logger = cachedRuntime?.logger ?? cachedRuntime?.log ?? cachedRuntime;
|
|
627
|
-
// Resolve account for activation
|
|
628
|
-
const accountId = payload?.accountId || "default";
|
|
629
|
-
const account = entry.resolveAccount(api.runtime.cfg, accountId);
|
|
630
|
-
await ensureConnection(account, logger);
|
|
631
|
-
respond(true, { active: true });
|
|
632
|
-
});
|
|
633
|
-
api.registerGatewayMethod("dragon.status", async ({ respond }) => {
|
|
634
|
-
respond(true, {
|
|
635
|
-
active: !!bridgeClient,
|
|
636
|
-
connected: !!bridgeClient,
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
},
|
|
640
|
-
setRuntime: (runtime) => {
|
|
641
|
-
cachedRuntime = runtime;
|
|
642
|
-
},
|
|
190
|
+
}
|
|
643
191
|
});
|
|
644
192
|
export default entry;
|