openclaw-glance-plugin 0.1.0

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 ADDED
@@ -0,0 +1,122 @@
1
+ # openclaw-glance
2
+
3
+ 智能盯盘:OpenClaw 插件客户端(连接 `openclaw-bridge`)。
4
+
5
+ OpenClaw 集成引导请看:[docs/openclaw-install-guide.md](./docs/openclaw-install-guide.md)
6
+
7
+ ## 插件定位
8
+
9
+ 这个插件用于让 OpenClaw 在“用户提出盯盘需求”时,快速完成以下动作:
10
+
11
+ - 建立与 `openclaw-bridge` 的长连接
12
+ - 把用户自然语言需求转成结构化策略并提交(`watch.create`)
13
+ - 在触发后实时接收 `watch.triggered` 回调给 OpenClaw 业务层
14
+ - 支持策略控制(激活、暂停、删除)
15
+
16
+ ## OpenClaw 调用时机
17
+
18
+ - 用户新建盯盘:调用 `submitWatchDemand` 或 `createWatch`
19
+ - 用户修改状态:调用 `activate` / `pause`
20
+ - 用户删除盯盘:调用 `remove`
21
+ - 需要接收触发消息:注册 `onTriggered` 并保持连接在线
22
+
23
+ ## 功能
24
+
25
+ - 与 `openclaw-bridge` 建立 WebSocket 长连接
26
+ - 支持请求:`watch.create` / `watch.activate` / `watch.pause` / `watch.delete` / `ping`
27
+ - 订阅推送:`watch.triggered`
28
+ - 自动重连 + 心跳
29
+ - 断线请求排队(可配置),重连后自动冲刷
30
+ - 提供业务适配层 `OpenClawPluginAdapter`
31
+
32
+ ## 安装
33
+
34
+ ```bash
35
+ npm install
36
+ ```
37
+
38
+ ## 测试
39
+
40
+ ```bash
41
+ npm test
42
+ ```
43
+
44
+ ## 运行示例
45
+
46
+ ```bash
47
+ OPENCLAW_BRIDGE_WS_BASE=ws://glanceup-pre.100credit.cn \
48
+ OPENCLAW_WS_TOKEN=your_ws_token \
49
+ npm start
50
+ ```
51
+
52
+ 运行适配层示例:
53
+
54
+ ```bash
55
+ npm run start:adapter
56
+ ```
57
+
58
+ ## SDK 使用
59
+
60
+ ```js
61
+ import { OpenClawBridgeClient } from './src/index.js';
62
+
63
+ const client = new OpenClawBridgeClient({
64
+ baseWsUrl: 'ws://glanceup-pre.100credit.cn',
65
+ token: '<JWT_TOKEN>',
66
+ enqueueIfDisconnected: true
67
+ });
68
+
69
+ client.on('triggered', (msg) => {
70
+ console.log(msg);
71
+ });
72
+
73
+ await client.connect();
74
+ const res = await client.createWatch({
75
+ product_code: '00700',
76
+ product_type: 'hk_stock',
77
+ operator_type: 'rule',
78
+ operator_parameters: {
79
+ condition: 'price < threshold',
80
+ variables: { threshold: 420 }
81
+ },
82
+ channels: ['openclaw'],
83
+ channel_configs: { openclaw: {} }
84
+ });
85
+ ```
86
+
87
+ ## 适配层用法
88
+
89
+ ```js
90
+ import { OpenClawPluginAdapter } from './src/index.js';
91
+
92
+ const adapter = new OpenClawPluginAdapter({
93
+ baseWsUrl: 'ws://glanceup-pre.100credit.cn',
94
+ token: '<JWT_TOKEN>'
95
+ });
96
+
97
+ await adapter.start();
98
+ await adapter.submitWatchDemand({
99
+ productCode: '06608',
100
+ productType: 'hk_stock',
101
+ condition: 'price >= threshold',
102
+ variables: { threshold: 8.97 },
103
+ channels: ['openclaw'],
104
+ channelConfigs: { openclaw: {} }
105
+ });
106
+ ```
107
+
108
+ ## 客户端事件
109
+
110
+ - `connected`
111
+ - `disconnected`
112
+ - `reconnecting`
113
+ - `warning`
114
+ - `error`
115
+ - `queued`(请求入队)
116
+ - `queueFlushed`(重连后队列发送)
117
+ - `triggered`
118
+
119
+ ## 说明
120
+
121
+ - 先获取 ws `token`,然后连接 `ws://<host>:8005/openclaw/ws`,并在握手 Header 传 `Authorization: Bearer <TOKEN>`。
122
+ - 发布时将导出 `src/` 与 `README.md`(见 `package.json` 的 `files/exports`)。
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "openclaw-glance-plugin",
3
+ "version": "0.1.0",
4
+ "description": "OpenClaw plugin client for ticker-monitor openclaw-bridge",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "node examples/basic.js",
16
+ "start:adapter": "node examples/adapter.js",
17
+ "test": "node --test tests/**/*.test.js"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "dependencies": {
23
+ "ws": "^8.18.0"
24
+ }
25
+ }
@@ -0,0 +1,360 @@
1
+ import EventEmitter from 'node:events';
2
+ import WebSocket from 'ws';
3
+
4
+ const DEFAULT_BASE_WS_URL = 'ws://glanceup-pre.100credit.cn';
5
+
6
+ function sleep(ms) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
9
+
10
+ function makeRequestId() {
11
+ return `req_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
12
+ }
13
+
14
+ /**
15
+ * 全局单例实例
16
+ * @type {OpenClawBridgeClient|null}
17
+ */
18
+ let globalInstance = null;
19
+
20
+ /**
21
+ * 获取全局单例实例
22
+ * @param {Object} options - 创建实例的选项(仅首次创建时有效)
23
+ * @returns {OpenClawBridgeClient} 全局单例实例
24
+ */
25
+ export function getGlobalClient(options) {
26
+ if (!globalInstance) {
27
+ globalInstance = new OpenClawBridgeClient(options);
28
+ }
29
+ return globalInstance;
30
+ }
31
+
32
+ /**
33
+ * 获取全局单例实例(别名,推荐使用)
34
+ * @param {Object} options - 创建实例的选项(仅首次创建时有效)
35
+ * @returns {OpenClawBridgeClient} 全局单例实例
36
+ */
37
+ export function getInstance(options) {
38
+ return getGlobalClient(options);
39
+ }
40
+
41
+ /**
42
+ * 重置全局单例实例(主要用于测试)
43
+ */
44
+ export async function resetGlobalClient() {
45
+ if (globalInstance) {
46
+ await globalInstance.close(true);
47
+ globalInstance = null;
48
+ }
49
+ }
50
+
51
+ export class OpenClawBridgeClient extends EventEmitter {
52
+ constructor(options) {
53
+ super();
54
+ const {
55
+ baseWsUrl = DEFAULT_BASE_WS_URL,
56
+ token = '',
57
+ heartbeatMs = 15000,
58
+ requestTimeoutMs = 10000,
59
+ waitConnectTimeoutMs = 20000,
60
+ reconnect = true,
61
+ reconnectBaseMs = 1000,
62
+ reconnectMaxMs = 15000,
63
+ enqueueIfDisconnected = true,
64
+ maxQueueSize = 200
65
+ } = options || {};
66
+
67
+ if (!token) throw new Error('token is required');
68
+
69
+ this.baseWsUrl = baseWsUrl.replace(/\/$/, '');
70
+ this.userId = '';
71
+ this.token = token;
72
+
73
+ this.heartbeatMs = heartbeatMs;
74
+ this.requestTimeoutMs = requestTimeoutMs;
75
+ this.waitConnectTimeoutMs = waitConnectTimeoutMs;
76
+ this.reconnect = reconnect;
77
+ this.reconnectBaseMs = reconnectBaseMs;
78
+ this.reconnectMaxMs = reconnectMaxMs;
79
+ this.enqueueIfDisconnected = enqueueIfDisconnected;
80
+ this.maxQueueSize = maxQueueSize;
81
+
82
+ this.ws = null;
83
+ this.connected = false;
84
+ this.stopped = false;
85
+ this.heartbeatTimer = null;
86
+ this.reconnectAttempt = 0;
87
+ this.pending = new Map();
88
+ this.requestQueue = [];
89
+ }
90
+
91
+ get wsUrl() {
92
+ const url = new URL('/openclaw/ws', this.baseWsUrl);
93
+ return url.toString();
94
+ }
95
+
96
+ async connect() {
97
+ this.stopped = false;
98
+ await this._connectOnce();
99
+ }
100
+
101
+ async close(force = true) {
102
+ this.stopped = Boolean(force);
103
+ this._clearHeartbeat();
104
+
105
+ for (const [requestId, waiter] of this.pending.entries()) {
106
+ clearTimeout(waiter.timer);
107
+ waiter.reject(new Error(`connection closed before response: ${requestId}`));
108
+ }
109
+ this.pending.clear();
110
+ for (const queued of this.requestQueue) {
111
+ queued.reject(new Error(`connection closed before request sent: ${queued.requestId}`));
112
+ }
113
+ this.requestQueue = [];
114
+
115
+ if (this.ws) {
116
+ try {
117
+ this.ws.close();
118
+ } catch (_err) {
119
+ // ignore
120
+ }
121
+ }
122
+ }
123
+
124
+ async createWatch(payload) {
125
+ return this._request('watch.create', payload);
126
+ }
127
+
128
+ async activateWatch(strategyId) {
129
+ return this._request('watch.activate', { strategy_id: strategyId });
130
+ }
131
+
132
+ async pauseWatch(strategyId) {
133
+ return this._request('watch.pause', { strategy_id: strategyId });
134
+ }
135
+
136
+ async deleteWatch(strategyId) {
137
+ return this._request('watch.delete', { strategy_id: strategyId });
138
+ }
139
+
140
+ async ping() {
141
+ return this._request('ping', {});
142
+ }
143
+
144
+ async waitUntilConnected(timeoutMs = this.waitConnectTimeoutMs) {
145
+ if (this.connected && this.ws && this.ws.readyState === WebSocket.OPEN) {
146
+ return true;
147
+ }
148
+
149
+ return new Promise((resolve, reject) => {
150
+ const timer = setTimeout(() => {
151
+ cleanup();
152
+ reject(new Error(`waitUntilConnected timeout (${timeoutMs}ms)`));
153
+ }, timeoutMs);
154
+
155
+ const onConnected = () => {
156
+ cleanup();
157
+ resolve(true);
158
+ };
159
+
160
+ const cleanup = () => {
161
+ clearTimeout(timer);
162
+ this.off('connected', onConnected);
163
+ };
164
+
165
+ this.on('connected', onConnected);
166
+ });
167
+ }
168
+
169
+ async _request(type, payload) {
170
+ const requestId = makeRequestId();
171
+ const msg = { type, request_id: requestId, payload: payload || {} };
172
+ const { promise, resolve, reject } = this._buildWaiter(type, requestId);
173
+
174
+ if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
175
+ if (!this.enqueueIfDisconnected) {
176
+ reject(new Error('websocket not connected'));
177
+ return promise;
178
+ }
179
+ if (this.requestQueue.length >= this.maxQueueSize) {
180
+ reject(new Error(`request queue overflow (max=${this.maxQueueSize})`));
181
+ return promise;
182
+ }
183
+ this.requestQueue.push({ msg, requestId, type, resolve, reject });
184
+ this.emit('queued', { type, requestId, queueSize: this.requestQueue.length });
185
+ return promise;
186
+ }
187
+
188
+ this._sendWithTimeout({ msg, requestId, type, resolve, reject });
189
+ return promise;
190
+ }
191
+
192
+ _buildWaiter(type, requestId) {
193
+ let resolve;
194
+ let reject;
195
+ const promise = new Promise((res, rej) => {
196
+ resolve = res;
197
+ reject = rej;
198
+ });
199
+ return { promise, resolve, reject, type, requestId };
200
+ }
201
+
202
+ _sendWithTimeout({ msg, requestId, type, resolve, reject }) {
203
+ const timer = setTimeout(() => {
204
+ this.pending.delete(requestId);
205
+ reject(new Error(`request timeout: ${type} (${requestId})`));
206
+ }, this.requestTimeoutMs);
207
+
208
+ this.pending.set(requestId, { resolve, reject, timer, type });
209
+ this.ws.send(JSON.stringify(msg));
210
+ }
211
+
212
+ async _connectOnce() {
213
+ const ws = new WebSocket(this.wsUrl, {
214
+ headers: {
215
+ Authorization: `Bearer ${this.token}`
216
+ }
217
+ });
218
+
219
+ await new Promise((resolve, reject) => {
220
+ const onOpen = () => {
221
+ cleanup();
222
+ resolve();
223
+ };
224
+ const onError = (err) => {
225
+ cleanup();
226
+ reject(err);
227
+ };
228
+ const cleanup = () => {
229
+ ws.off('open', onOpen);
230
+ ws.off('error', onError);
231
+ };
232
+ ws.on('open', onOpen);
233
+ ws.on('error', onError);
234
+ });
235
+
236
+ this.ws = ws;
237
+ this.connected = true;
238
+ this.reconnectAttempt = 0;
239
+ this.emit('connected');
240
+ this._flushQueue();
241
+
242
+ ws.on('message', (raw) => {
243
+ this._onMessage(raw.toString());
244
+ });
245
+
246
+ ws.on('close', (code, reason) => {
247
+ this._onClose(code, reason?.toString());
248
+ });
249
+
250
+ ws.on('error', (err) => {
251
+ this.emit('error', err);
252
+ });
253
+
254
+ this._startHeartbeat();
255
+ }
256
+
257
+ _onMessage(raw) {
258
+ let msg;
259
+ try {
260
+ msg = JSON.parse(raw);
261
+ } catch (err) {
262
+ this.emit('warning', new Error(`invalid json from bridge: ${raw}`));
263
+ return;
264
+ }
265
+
266
+ const requestId = msg.request_id;
267
+ if (requestId && this.pending.has(requestId)) {
268
+ const waiter = this.pending.get(requestId);
269
+ this.pending.delete(requestId);
270
+ clearTimeout(waiter.timer);
271
+ waiter.resolve(msg);
272
+ return;
273
+ }
274
+
275
+ if (msg.type === 'watch.triggered') {
276
+ this.emit('triggered', msg);
277
+ return;
278
+ }
279
+
280
+ if (msg.type === 'system.connected') {
281
+ this.userId = msg.user_id || this.userId;
282
+ this.emit('systemConnected', msg);
283
+ return;
284
+ }
285
+
286
+ this.emit('message', msg);
287
+ }
288
+
289
+ async _onClose(code, reason) {
290
+ this.connected = false;
291
+ this._clearHeartbeat();
292
+ this.emit('disconnected', { code, reason, reconnect: this.reconnect, stopped: this.stopped });
293
+
294
+ if (this.stopped) {
295
+ return;
296
+ }
297
+
298
+ // 如果不是主动停止,则自动重连
299
+ if (!this.reconnect) {
300
+ return;
301
+ }
302
+
303
+ while (!this.stopped) {
304
+ this.reconnectAttempt += 1;
305
+ const backoff = Math.min(
306
+ this.reconnectBaseMs * Math.min(this.reconnectAttempt, 10),
307
+ this.reconnectMaxMs
308
+ );
309
+ this.emit('reconnecting', { attempt: this.reconnectAttempt, backoffMs: backoff, reason: 'auto' });
310
+ await sleep(backoff);
311
+
312
+ if (this.stopped) {
313
+ break;
314
+ }
315
+
316
+ try {
317
+ await this._connectOnce();
318
+ this.emit('reconnected', { attempt: this.reconnectAttempt });
319
+ return;
320
+ } catch (err) {
321
+ const reconnectErr = new Error(`重连失败: ${err.message}`);
322
+ reconnectErr.attempt = this.reconnectAttempt;
323
+ this.emit('error', reconnectErr);
324
+ }
325
+ }
326
+ }
327
+
328
+ _startHeartbeat() {
329
+ this._clearHeartbeat();
330
+ this.heartbeatTimer = setInterval(async () => {
331
+ if (!this.connected) return;
332
+ try {
333
+ await this.ping();
334
+ } catch (err) {
335
+ this.emit('warning', new Error(`heartbeat failed: ${err.message}`));
336
+ }
337
+ }, this.heartbeatMs);
338
+ }
339
+
340
+ _flushQueue() {
341
+ if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
342
+ return;
343
+ }
344
+ if (!this.requestQueue.length) {
345
+ return;
346
+ }
347
+ const queued = this.requestQueue.splice(0, this.requestQueue.length);
348
+ for (const item of queued) {
349
+ this._sendWithTimeout(item);
350
+ }
351
+ this.emit('queueFlushed', { count: queued.length });
352
+ }
353
+
354
+ _clearHeartbeat() {
355
+ if (this.heartbeatTimer) {
356
+ clearInterval(this.heartbeatTimer);
357
+ this.heartbeatTimer = null;
358
+ }
359
+ }
360
+ }
@@ -0,0 +1,104 @@
1
+ import { OpenClawBridgeClient } from './OpenClawBridgeClient.js';
2
+
3
+ /**
4
+ * 全局单例 Adapter 实例
5
+ * @type {OpenClawPluginAdapter|null}
6
+ */
7
+ let globalAdapterInstance = null;
8
+
9
+ /**
10
+ * 获取全局单例 Adapter 实例
11
+ * @param {Object} clientOrOptions - 客户端实例或配置选项
12
+ * @returns {OpenClawPluginAdapter} 全局单例 Adapter 实例
13
+ */
14
+ export function getGlobalAdapter(clientOrOptions) {
15
+ if (!globalAdapterInstance) {
16
+ globalAdapterInstance = new OpenClawPluginAdapter(clientOrOptions);
17
+ }
18
+ return globalAdapterInstance;
19
+ }
20
+
21
+ /**
22
+ * 获取全局单例 Adapter 实例(别名,推荐使用)
23
+ * @param {Object} clientOrOptions - 客户端实例或配置选项
24
+ * @returns {OpenClawPluginAdapter} 全局单例 Adapter 实例
25
+ */
26
+ export function getAdapter(clientOrOptions) {
27
+ return getGlobalAdapter(clientOrOptions);
28
+ }
29
+
30
+ /**
31
+ * 重置全局单例 Adapter(主要用于测试)
32
+ */
33
+ export async function resetGlobalAdapter() {
34
+ if (globalAdapterInstance) {
35
+ await globalAdapterInstance.stop();
36
+ globalAdapterInstance = null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 面向 OpenClaw 业务层的薄适配器。
42
+ * 负责把业务输入转换为 bridge 协议中的 watch.* 请求。
43
+ * 支持单例模式:通过 getAdapter() 或 getGlobalAdapter() 获取全局实例
44
+ */
45
+ export class OpenClawPluginAdapter {
46
+ constructor(clientOrOptions) {
47
+ const looksLikeClient =
48
+ clientOrOptions &&
49
+ typeof clientOrOptions === 'object' &&
50
+ typeof clientOrOptions.createWatch === 'function' &&
51
+ typeof clientOrOptions.pauseWatch === 'function' &&
52
+ typeof clientOrOptions.activateWatch === 'function' &&
53
+ typeof clientOrOptions.deleteWatch === 'function';
54
+
55
+ if (clientOrOptions instanceof OpenClawBridgeClient || looksLikeClient) {
56
+ this.client = clientOrOptions;
57
+ } else {
58
+ this.client = new OpenClawBridgeClient(clientOrOptions || {});
59
+ }
60
+ }
61
+
62
+ async start() {
63
+ await this.client.connect();
64
+ }
65
+
66
+ async stop() {
67
+ await this.client.close(true);
68
+ }
69
+
70
+ onTriggered(handler) {
71
+ this.client.on('triggered', handler);
72
+ }
73
+
74
+ /**
75
+ * 统一创建盯盘需求接口(适配 OpenClaw 侧参数)。
76
+ */
77
+ async submitWatchDemand(demand) {
78
+ const payload = {
79
+ product_code: demand.productCode,
80
+ product_type: demand.productType || 'stock',
81
+ operator_type: 'rule',
82
+ operator_parameters: {
83
+ condition: demand.condition,
84
+ variables: demand.variables || {},
85
+ message_template: demand.messageTemplate
86
+ },
87
+ channels: demand.channels || ['openclaw'],
88
+ channel_configs: demand.channelConfigs || { openclaw: {} }
89
+ };
90
+ return this.client.createWatch(payload);
91
+ }
92
+
93
+ async pause(strategyId) {
94
+ return this.client.pauseWatch(strategyId);
95
+ }
96
+
97
+ async activate(strategyId) {
98
+ return this.client.activateWatch(strategyId);
99
+ }
100
+
101
+ async remove(strategyId) {
102
+ return this.client.deleteWatch(strategyId);
103
+ }
104
+ }
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { OpenClawBridgeClient, getGlobalClient, getInstance, resetGlobalClient } from './OpenClawBridgeClient.js';
2
+ export { OpenClawPluginAdapter, getGlobalAdapter, getAdapter, resetGlobalAdapter } from './OpenClawPluginAdapter.js';