openclaw-glance-plugin 0.1.2 → 0.1.4
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 +8 -10
- package/index.js +3 -0
- package/openclaw.plugin.json +21 -0
- package/package.json +7 -3
- package/skills/glance-watch/SKILL.md +290 -0
- package/skills/glance-watch/data/index_a.csv +12599 -0
- package/skills/glance-watch/data/stock_a.csv +5489 -0
- package/skills/glance-watch/data/stock_hk.csv +2723 -0
- package/skills/glance-watch/references/markets.md +56 -0
- package/src/OpenClawBridgeClient.js +4 -0
- package/src/OpenClawPluginAdapter.js +23 -0
- package/src/config/runtime-config.js +50 -0
- package/src/index.js +10 -1
- package/src/plugin/index.js +228 -0
- package/src/runtime/BridgeRuntime.js +254 -0
- package/src/runtime/dispatchers/PluginDispatcher.js +18 -0
- package/src/runtime/lock/ProcessLock.js +160 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# 盯盘插件参考文档
|
|
2
|
+
|
|
3
|
+
## 环境配置
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
# 基础配置(桥接地址固定为 wss://glanceup-pre.100credit.cn)
|
|
7
|
+
export OPENCLAW_WS_TOKEN="<token>"
|
|
8
|
+
|
|
9
|
+
# Token 申请:在网页上申请 OPENCLAW_WS_TOKEN
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 市场类型与产品代码
|
|
13
|
+
|
|
14
|
+
| 市场 | productType | productCode 示例 | 行情频率 |
|
|
15
|
+
|------|-------------|-----------------|----------|
|
|
16
|
+
| A股个股 | stock | 000001 | 约每 3 秒 |
|
|
17
|
+
| A股指数 | index | 000300 | 约每 3 秒 |
|
|
18
|
+
| 港股 | hk_stock | 00700 | 延迟约 15 分钟 |
|
|
19
|
+
| 比特币 | crypto | BTCUSDT | 约每 10 秒 |
|
|
20
|
+
|
|
21
|
+
## 条件表达式
|
|
22
|
+
|
|
23
|
+
### 可用变量
|
|
24
|
+
|
|
25
|
+
- 通用: `price`, `volume`, `change_percent`
|
|
26
|
+
- A股/港股额外: `turnover_rate`
|
|
27
|
+
- 比特币: 不支持 `turnover_rate`
|
|
28
|
+
|
|
29
|
+
### 示例条件
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
// 单一条件
|
|
33
|
+
'price >= threshold'
|
|
34
|
+
|
|
35
|
+
// 多条件
|
|
36
|
+
'price >= threshold and change_percent >= cp_threshold'
|
|
37
|
+
|
|
38
|
+
// A股专用
|
|
39
|
+
'price >= threshold and turnover_rate >= tr_threshold'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 产品代码速查
|
|
43
|
+
|
|
44
|
+
### A股主要指数
|
|
45
|
+
- 沪深300: 000300
|
|
46
|
+
- 上证指数: 000001
|
|
47
|
+
- 创业板指: 399006
|
|
48
|
+
|
|
49
|
+
### 热门港股
|
|
50
|
+
- 腾讯控股: 00700
|
|
51
|
+
- 阿里巴巴: 09988
|
|
52
|
+
- 美团: 03690
|
|
53
|
+
- 比亚迪股份: 01211
|
|
54
|
+
|
|
55
|
+
### 主流加密货币
|
|
56
|
+
- 比特币: BTCUSDT
|
|
@@ -141,6 +141,10 @@ export class OpenClawBridgeClient extends EventEmitter {
|
|
|
141
141
|
return this._request('ping', {});
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
async queryTickerData(payload) {
|
|
145
|
+
return this._request('ticker.query', payload || {});
|
|
146
|
+
}
|
|
147
|
+
|
|
144
148
|
async waitUntilConnected(timeoutMs = this.waitConnectTimeoutMs) {
|
|
145
149
|
if (this.connected && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
146
150
|
return true;
|
|
@@ -94,6 +94,10 @@ export class OpenClawPluginAdapter {
|
|
|
94
94
|
channelConfigs.call = demand.callConfig;
|
|
95
95
|
if (!channels.includes('call')) channels.push('call');
|
|
96
96
|
}
|
|
97
|
+
if (demand.smsConfig) {
|
|
98
|
+
channelConfigs.sms = demand.smsConfig;
|
|
99
|
+
if (!channels.includes('sms')) channels.push('sms');
|
|
100
|
+
}
|
|
97
101
|
if (!channels.includes('openclaw')) channels.unshift('openclaw');
|
|
98
102
|
if (!channelConfigs.openclaw) channelConfigs.openclaw = {};
|
|
99
103
|
|
|
@@ -112,6 +116,25 @@ export class OpenClawPluginAdapter {
|
|
|
112
116
|
return this.client.createWatch(payload);
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
/**
|
|
120
|
+
* 查询标的实时行情(透传到 bridge 的 ticker.query)。
|
|
121
|
+
*/
|
|
122
|
+
async queryTickerData(query) {
|
|
123
|
+
const stockCode = query?.stockCode || query?.productCode || query?.stock_code || '';
|
|
124
|
+
const productType = query?.productType || query?.product_type || '';
|
|
125
|
+
let market = query?.market;
|
|
126
|
+
|
|
127
|
+
if (market == null && String(productType).toLowerCase() === 'crypto') {
|
|
128
|
+
market = '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return this.client.queryTickerData({
|
|
132
|
+
stock_code: stockCode,
|
|
133
|
+
market: market == null ? '' : String(market),
|
|
134
|
+
product_type: productType
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
async pause(strategyId) {
|
|
116
139
|
return this.client.pauseWatch(strategyId);
|
|
117
140
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
|
|
4
|
+
import { ProcessLock } from '../runtime/lock/ProcessLock.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BASE_WS_URL = 'wss://glanceup-pre.100credit.cn';
|
|
7
|
+
|
|
8
|
+
function pick(source, keys, fallback = undefined) {
|
|
9
|
+
for (const key of keys) {
|
|
10
|
+
const value = source?.[key];
|
|
11
|
+
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveRuntimeConfig({ env = process.env, pluginConfig = {} } = {}) {
|
|
19
|
+
const baseWsUrl = String(
|
|
20
|
+
pick(pluginConfig, ['baseWsUrl', 'base_ws_url'], pick(env, ['OPENCLAW_BASE_WS_URL'], DEFAULT_BASE_WS_URL))
|
|
21
|
+
);
|
|
22
|
+
const token = String(pick(pluginConfig, ['token'], pick(env, ['OPENCLAW_WS_TOKEN'], '')));
|
|
23
|
+
const lockDir = String(
|
|
24
|
+
pick(pluginConfig, ['lockDir', 'lock_dir'], pick(env, ['OPENCLAW_LOCK_DIR'], path.join(process.cwd(), '.openclaw-locks')))
|
|
25
|
+
);
|
|
26
|
+
const lockKey = ProcessLock.buildLockKey(baseWsUrl, token);
|
|
27
|
+
if (!token) {
|
|
28
|
+
throw new Error('token is required');
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const parsed = new URL(baseWsUrl);
|
|
32
|
+
if (!parsed.protocol || !parsed.host) {
|
|
33
|
+
throw new Error('invalid baseWsUrl');
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
|
|
36
|
+
throw new Error(`invalid baseWsUrl protocol: ${parsed.protocol}`);
|
|
37
|
+
}
|
|
38
|
+
} catch (_err) {
|
|
39
|
+
if (String(_err?.message || '').toLowerCase().includes('protocol')) {
|
|
40
|
+
throw _err;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`invalid baseWsUrl: ${baseWsUrl}`);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
baseWsUrl,
|
|
46
|
+
token,
|
|
47
|
+
lockDir,
|
|
48
|
+
lockKey
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
export { OpenClawBridgeClient, getGlobalClient, getInstance, resetGlobalClient } from './OpenClawBridgeClient.js';
|
|
2
|
-
export { OpenClawPluginAdapter, getGlobalAdapter, getAdapter, resetGlobalAdapter } from './OpenClawPluginAdapter.js';
|
|
2
|
+
export { OpenClawPluginAdapter, getGlobalAdapter, getAdapter, resetGlobalAdapter } from './OpenClawPluginAdapter.js';
|
|
3
|
+
export { BridgeRuntime } from './runtime/BridgeRuntime.js';
|
|
4
|
+
export { ProcessLock, SingleActiveConflictError } from './runtime/lock/ProcessLock.js';
|
|
5
|
+
export { PluginDispatcher } from './runtime/dispatchers/PluginDispatcher.js';
|
|
6
|
+
export { resolveRuntimeConfig } from './config/runtime-config.js';
|
|
7
|
+
export {
|
|
8
|
+
startPluginRuntime,
|
|
9
|
+
stopPluginRuntime,
|
|
10
|
+
getActivePluginRuntime
|
|
11
|
+
} from './plugin/index.js';
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { resolveRuntimeConfig } from '../config/runtime-config.js';
|
|
2
|
+
import { BridgeRuntime } from '../runtime/BridgeRuntime.js';
|
|
3
|
+
import { PluginDispatcher } from '../runtime/dispatchers/PluginDispatcher.js';
|
|
4
|
+
import { ProcessLock } from '../runtime/lock/ProcessLock.js';
|
|
5
|
+
|
|
6
|
+
let activeRuntime = null;
|
|
7
|
+
|
|
8
|
+
function installProcessShutdown(runtime) {
|
|
9
|
+
const stop = async () => {
|
|
10
|
+
if (!runtime) return;
|
|
11
|
+
await runtime.stop().catch(() => {});
|
|
12
|
+
process.exit(0);
|
|
13
|
+
};
|
|
14
|
+
process.once('SIGINT', stop);
|
|
15
|
+
process.once('SIGTERM', stop);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getActivePluginRuntime() {
|
|
19
|
+
return activeRuntime;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function startPluginRuntime({ runtime, pluginConfig } = {}) {
|
|
23
|
+
if (activeRuntime) {
|
|
24
|
+
return activeRuntime;
|
|
25
|
+
}
|
|
26
|
+
const config = resolveRuntimeConfig({ pluginConfig });
|
|
27
|
+
const lock = new ProcessLock({
|
|
28
|
+
lockDir: config.lockDir,
|
|
29
|
+
key: config.lockKey
|
|
30
|
+
});
|
|
31
|
+
const dispatcher = new PluginDispatcher({ runtime });
|
|
32
|
+
activeRuntime = new BridgeRuntime({
|
|
33
|
+
baseWsUrl: config.baseWsUrl,
|
|
34
|
+
token: config.token,
|
|
35
|
+
dispatcher,
|
|
36
|
+
lock
|
|
37
|
+
});
|
|
38
|
+
await activeRuntime.start();
|
|
39
|
+
return activeRuntime;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function stopPluginRuntime() {
|
|
43
|
+
if (!activeRuntime) return;
|
|
44
|
+
await activeRuntime.stop();
|
|
45
|
+
activeRuntime = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getReadyRuntime(startupPromise) {
|
|
49
|
+
await startupPromise;
|
|
50
|
+
if (!activeRuntime) {
|
|
51
|
+
throw new Error('plugin runtime is not active');
|
|
52
|
+
}
|
|
53
|
+
return activeRuntime;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mapDemandToCreatePayload(demand = {}) {
|
|
57
|
+
const channels = Array.isArray(demand.channels)
|
|
58
|
+
? demand.channels
|
|
59
|
+
.filter((x) => typeof x === 'string' && x.trim())
|
|
60
|
+
.map((x) => x.trim().toLowerCase())
|
|
61
|
+
: [];
|
|
62
|
+
const channelConfigs = { ...(demand.channelConfigs || {}) };
|
|
63
|
+
|
|
64
|
+
if (demand.openclawConfig) {
|
|
65
|
+
channelConfigs.openclaw = demand.openclawConfig;
|
|
66
|
+
if (!channels.includes('openclaw')) channels.push('openclaw');
|
|
67
|
+
}
|
|
68
|
+
if (demand.emailConfig) {
|
|
69
|
+
channelConfigs.email = demand.emailConfig;
|
|
70
|
+
if (!channels.includes('email')) channels.push('email');
|
|
71
|
+
}
|
|
72
|
+
if (demand.callConfig) {
|
|
73
|
+
channelConfigs.call = demand.callConfig;
|
|
74
|
+
if (!channels.includes('call')) channels.push('call');
|
|
75
|
+
}
|
|
76
|
+
if (demand.smsConfig) {
|
|
77
|
+
channelConfigs.sms = demand.smsConfig;
|
|
78
|
+
if (!channels.includes('sms')) channels.push('sms');
|
|
79
|
+
}
|
|
80
|
+
if (!channels.includes('openclaw')) channels.unshift('openclaw');
|
|
81
|
+
if (!channelConfigs.openclaw) channelConfigs.openclaw = {};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
product_code: demand.productCode || demand.product_code,
|
|
85
|
+
product_type: demand.productType || demand.product_type || 'stock',
|
|
86
|
+
operator_type: 'rule',
|
|
87
|
+
operator_parameters: {
|
|
88
|
+
condition: demand.condition,
|
|
89
|
+
variables: demand.variables || {},
|
|
90
|
+
message_template: demand.messageTemplate || demand.message_template
|
|
91
|
+
},
|
|
92
|
+
channels,
|
|
93
|
+
channel_configs: channelConfigs
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildControlApi(startupPromise) {
|
|
98
|
+
return {
|
|
99
|
+
async queryTickerData(query = {}) {
|
|
100
|
+
const runtime = await getReadyRuntime(startupPromise);
|
|
101
|
+
const stockCode = query.stockCode || query.productCode || query.stock_code || '';
|
|
102
|
+
const productType = query.productType || query.product_type || '';
|
|
103
|
+
let market = query.market;
|
|
104
|
+
if (market == null && String(productType).toLowerCase() === 'crypto') {
|
|
105
|
+
market = '';
|
|
106
|
+
}
|
|
107
|
+
return runtime.request('ticker.query', {
|
|
108
|
+
stock_code: stockCode,
|
|
109
|
+
market: market == null ? '' : String(market),
|
|
110
|
+
product_type: productType
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
async createWatch(payload = {}) {
|
|
114
|
+
const runtime = await getReadyRuntime(startupPromise);
|
|
115
|
+
return runtime.request('watch.create', payload);
|
|
116
|
+
},
|
|
117
|
+
async submitWatchDemand(demand = {}) {
|
|
118
|
+
const runtime = await getReadyRuntime(startupPromise);
|
|
119
|
+
return runtime.request('watch.create', mapDemandToCreatePayload(demand));
|
|
120
|
+
},
|
|
121
|
+
async pauseWatch(strategyId) {
|
|
122
|
+
const runtime = await getReadyRuntime(startupPromise);
|
|
123
|
+
return runtime.request('watch.pause', { strategy_id: strategyId });
|
|
124
|
+
},
|
|
125
|
+
async activateWatch(strategyId) {
|
|
126
|
+
const runtime = await getReadyRuntime(startupPromise);
|
|
127
|
+
return runtime.request('watch.activate', { strategy_id: strategyId });
|
|
128
|
+
},
|
|
129
|
+
async deleteWatch(strategyId) {
|
|
130
|
+
const runtime = await getReadyRuntime(startupPromise);
|
|
131
|
+
return runtime.request('watch.delete', { strategy_id: strategyId });
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function tryRegisterTool(registerTool, name, description, handler) {
|
|
137
|
+
if (typeof registerTool !== 'function') return;
|
|
138
|
+
const def = {
|
|
139
|
+
name,
|
|
140
|
+
description,
|
|
141
|
+
handler,
|
|
142
|
+
execute: async (_toolCallId, params) => handler(params || {})
|
|
143
|
+
};
|
|
144
|
+
const meta = {
|
|
145
|
+
name,
|
|
146
|
+
description
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// OpenClaw-style: registerTool(def, meta)
|
|
150
|
+
try {
|
|
151
|
+
registerTool(def, meta);
|
|
152
|
+
return;
|
|
153
|
+
} catch (_err) {
|
|
154
|
+
// try one-arg object signature
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
registerTool(def);
|
|
159
|
+
return;
|
|
160
|
+
} catch (_err) {
|
|
161
|
+
// try alternate host signature: (name, handler)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
registerTool(name, handler);
|
|
166
|
+
} catch (_err) {
|
|
167
|
+
// ignore host differences
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function registerControlTools(api, controlApi) {
|
|
172
|
+
const registerTool = api?.registerTool || api?.runtime?.registerTool;
|
|
173
|
+
tryRegisterTool(registerTool, 'watch.query_ticker', 'Query ticker data', (args) =>
|
|
174
|
+
controlApi.queryTickerData(args || {})
|
|
175
|
+
);
|
|
176
|
+
tryRegisterTool(registerTool, 'watch.create', 'Create watch strategy', (args) =>
|
|
177
|
+
controlApi.createWatch(args || {})
|
|
178
|
+
);
|
|
179
|
+
tryRegisterTool(registerTool, 'watch.pause', 'Pause watch strategy', (args) =>
|
|
180
|
+
controlApi.pauseWatch(args?.strategyId || args?.strategy_id)
|
|
181
|
+
);
|
|
182
|
+
tryRegisterTool(registerTool, 'watch.activate', 'Activate watch strategy', (args) =>
|
|
183
|
+
controlApi.activateWatch(args?.strategyId || args?.strategy_id)
|
|
184
|
+
);
|
|
185
|
+
tryRegisterTool(registerTool, 'watch.remove', 'Delete watch strategy', (args) =>
|
|
186
|
+
controlApi.deleteWatch(args?.strategyId || args?.strategy_id)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const plugin = {
|
|
191
|
+
id: 'glance-bridge',
|
|
192
|
+
name: 'Glance Bridge Channel',
|
|
193
|
+
description: 'OpenClaw bridge long connection plugin',
|
|
194
|
+
register(api) {
|
|
195
|
+
const pluginConfig =
|
|
196
|
+
api?.config?.channels?.['glance-bridge'] ||
|
|
197
|
+
api?.config?.channels?.glanceBridge ||
|
|
198
|
+
api?.config?.glanceBridge ||
|
|
199
|
+
{};
|
|
200
|
+
|
|
201
|
+
const startupPromise = startPluginRuntime({
|
|
202
|
+
runtime: api?.runtime,
|
|
203
|
+
pluginConfig
|
|
204
|
+
});
|
|
205
|
+
startupPromise.catch((err) => {
|
|
206
|
+
api?.runtime?.logger?.error?.(`[glance-bridge] runtime start failed: ${err.message}`);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const controlApi = buildControlApi(startupPromise);
|
|
210
|
+
api.glanceBridge = controlApi;
|
|
211
|
+
registerControlTools(api, controlApi);
|
|
212
|
+
|
|
213
|
+
if (typeof api?.onShutdown === 'function') {
|
|
214
|
+
api.onShutdown(async () => {
|
|
215
|
+
await startupPromise.catch(() => {});
|
|
216
|
+
await stopPluginRuntime();
|
|
217
|
+
});
|
|
218
|
+
} else {
|
|
219
|
+
startupPromise
|
|
220
|
+
.then((runtime) => {
|
|
221
|
+
installProcessShutdown(runtime);
|
|
222
|
+
})
|
|
223
|
+
.catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export default plugin;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import EventEmitter from 'node:events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
|
|
4
|
+
function sleep(ms) {
|
|
5
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function makeRequestId() {
|
|
9
|
+
return `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class BridgeRuntime extends EventEmitter {
|
|
13
|
+
constructor({
|
|
14
|
+
baseWsUrl,
|
|
15
|
+
token,
|
|
16
|
+
dispatcher,
|
|
17
|
+
lock,
|
|
18
|
+
heartbeatMs = 15000,
|
|
19
|
+
requestTimeoutMs = 10000,
|
|
20
|
+
reconnect = true,
|
|
21
|
+
reconnectBaseMs = 1000,
|
|
22
|
+
reconnectMaxMs = 15000,
|
|
23
|
+
enqueueIfDisconnected = true,
|
|
24
|
+
maxQueueSize = 200
|
|
25
|
+
}) {
|
|
26
|
+
super();
|
|
27
|
+
if (!baseWsUrl) throw new Error('baseWsUrl is required');
|
|
28
|
+
if (!token) throw new Error('token is required');
|
|
29
|
+
this.baseWsUrl = baseWsUrl.replace(/\/$/, '');
|
|
30
|
+
this.token = token;
|
|
31
|
+
this.dispatcher = dispatcher;
|
|
32
|
+
this.lock = lock;
|
|
33
|
+
|
|
34
|
+
this.heartbeatMs = heartbeatMs;
|
|
35
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
36
|
+
this.reconnect = reconnect;
|
|
37
|
+
this.reconnectBaseMs = reconnectBaseMs;
|
|
38
|
+
this.reconnectMaxMs = reconnectMaxMs;
|
|
39
|
+
this.enqueueIfDisconnected = enqueueIfDisconnected;
|
|
40
|
+
this.maxQueueSize = maxQueueSize;
|
|
41
|
+
|
|
42
|
+
this.ws = null;
|
|
43
|
+
this.connected = false;
|
|
44
|
+
this.stopped = false;
|
|
45
|
+
this.reconnectAttempt = 0;
|
|
46
|
+
this.heartbeatTimer = null;
|
|
47
|
+
this.pending = new Map();
|
|
48
|
+
this.requestQueue = [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get wsUrl() {
|
|
52
|
+
const url = new URL('/openclaw/ws', this.baseWsUrl);
|
|
53
|
+
return url.toString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async start() {
|
|
57
|
+
this.stopped = false;
|
|
58
|
+
await this.lock?.acquire();
|
|
59
|
+
try {
|
|
60
|
+
await this._connectOnce();
|
|
61
|
+
} catch (err) {
|
|
62
|
+
await this.lock?.release().catch(() => {});
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async stop() {
|
|
68
|
+
this.stopped = true;
|
|
69
|
+
this.connected = false;
|
|
70
|
+
this._clearHeartbeat();
|
|
71
|
+
for (const [requestId, waiter] of this.pending.entries()) {
|
|
72
|
+
clearTimeout(waiter.timer);
|
|
73
|
+
waiter.reject(new Error(`connection closed before response: ${requestId}`));
|
|
74
|
+
}
|
|
75
|
+
this.pending.clear();
|
|
76
|
+
for (const queued of this.requestQueue) {
|
|
77
|
+
queued.reject(new Error(`connection closed before request sent: ${queued.requestId}`));
|
|
78
|
+
}
|
|
79
|
+
this.requestQueue = [];
|
|
80
|
+
|
|
81
|
+
if (this.ws) {
|
|
82
|
+
try {
|
|
83
|
+
this.ws.close();
|
|
84
|
+
} catch (_err) {
|
|
85
|
+
// ignore
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await this.lock?.release().catch(() => {});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async request(type, payload = {}) {
|
|
92
|
+
const requestId = makeRequestId();
|
|
93
|
+
const msg = { type, request_id: requestId, payload };
|
|
94
|
+
const { promise, resolve, reject } = this._buildWaiter();
|
|
95
|
+
|
|
96
|
+
if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
97
|
+
if (!this.enqueueIfDisconnected) {
|
|
98
|
+
reject(new Error('websocket not connected'));
|
|
99
|
+
return promise;
|
|
100
|
+
}
|
|
101
|
+
if (this.requestQueue.length >= this.maxQueueSize) {
|
|
102
|
+
reject(new Error(`request queue overflow (max=${this.maxQueueSize})`));
|
|
103
|
+
return promise;
|
|
104
|
+
}
|
|
105
|
+
this.requestQueue.push({ msg, requestId, resolve, reject });
|
|
106
|
+
return promise;
|
|
107
|
+
}
|
|
108
|
+
this._sendWithTimeout({ msg, requestId, resolve, reject });
|
|
109
|
+
return promise;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_buildWaiter() {
|
|
113
|
+
let resolve;
|
|
114
|
+
let reject;
|
|
115
|
+
const promise = new Promise((res, rej) => {
|
|
116
|
+
resolve = res;
|
|
117
|
+
reject = rej;
|
|
118
|
+
});
|
|
119
|
+
return { promise, resolve, reject };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_sendWithTimeout({ msg, requestId, resolve, reject }) {
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
this.pending.delete(requestId);
|
|
125
|
+
reject(new Error(`request timeout: ${msg.type} (${requestId})`));
|
|
126
|
+
}, this.requestTimeoutMs);
|
|
127
|
+
|
|
128
|
+
this.pending.set(requestId, { resolve, reject, timer });
|
|
129
|
+
try {
|
|
130
|
+
this.ws.send(JSON.stringify(msg));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
this.pending.delete(requestId);
|
|
134
|
+
reject(err);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async _connectOnce() {
|
|
139
|
+
const ws = new WebSocket(this.wsUrl, {
|
|
140
|
+
headers: {
|
|
141
|
+
Authorization: `Bearer ${this.token}`
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await new Promise((resolve, reject) => {
|
|
146
|
+
const onOpen = () => {
|
|
147
|
+
cleanup();
|
|
148
|
+
resolve();
|
|
149
|
+
};
|
|
150
|
+
const onError = (err) => {
|
|
151
|
+
cleanup();
|
|
152
|
+
reject(err);
|
|
153
|
+
};
|
|
154
|
+
const cleanup = () => {
|
|
155
|
+
ws.off('open', onOpen);
|
|
156
|
+
ws.off('error', onError);
|
|
157
|
+
};
|
|
158
|
+
ws.on('open', onOpen);
|
|
159
|
+
ws.on('error', onError);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
this.ws = ws;
|
|
163
|
+
this.connected = true;
|
|
164
|
+
this.reconnectAttempt = 0;
|
|
165
|
+
this.emit('connected');
|
|
166
|
+
this._startHeartbeat();
|
|
167
|
+
this._flushQueue();
|
|
168
|
+
|
|
169
|
+
ws.on('message', (raw) => {
|
|
170
|
+
this._onMessage(raw.toString());
|
|
171
|
+
});
|
|
172
|
+
ws.on('close', (code, reason) => {
|
|
173
|
+
void this._onClose(code, reason?.toString());
|
|
174
|
+
});
|
|
175
|
+
ws.on('error', (err) => {
|
|
176
|
+
this.emit('error', err);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_onMessage(raw) {
|
|
181
|
+
let msg;
|
|
182
|
+
try {
|
|
183
|
+
msg = JSON.parse(raw);
|
|
184
|
+
} catch (_err) {
|
|
185
|
+
this.emit('warning', new Error(`invalid json from bridge: ${raw}`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const requestId = msg.request_id;
|
|
189
|
+
if (requestId && this.pending.has(requestId)) {
|
|
190
|
+
const waiter = this.pending.get(requestId);
|
|
191
|
+
this.pending.delete(requestId);
|
|
192
|
+
clearTimeout(waiter.timer);
|
|
193
|
+
waiter.resolve(msg);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (msg.type === 'watch.triggered') {
|
|
197
|
+
this.dispatcher?.onTriggered?.(msg).catch((err) => this.emit('error', err));
|
|
198
|
+
this.emit('triggered', msg);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.emit('message', msg);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async _onClose(code, reason) {
|
|
205
|
+
this.connected = false;
|
|
206
|
+
this._clearHeartbeat();
|
|
207
|
+
this.emit('disconnected', { code, reason });
|
|
208
|
+
if (this.stopped || !this.reconnect) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
while (!this.stopped) {
|
|
212
|
+
this.reconnectAttempt += 1;
|
|
213
|
+
const backoff = Math.min(
|
|
214
|
+
this.reconnectBaseMs * Math.min(this.reconnectAttempt, 10),
|
|
215
|
+
this.reconnectMaxMs
|
|
216
|
+
);
|
|
217
|
+
this.emit('reconnecting', { attempt: this.reconnectAttempt, backoffMs: backoff });
|
|
218
|
+
await sleep(backoff);
|
|
219
|
+
if (this.stopped) return;
|
|
220
|
+
try {
|
|
221
|
+
await this._connectOnce();
|
|
222
|
+
this.emit('reconnected', { attempt: this.reconnectAttempt });
|
|
223
|
+
return;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
this.emit('warning', err);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_startHeartbeat() {
|
|
231
|
+
this._clearHeartbeat();
|
|
232
|
+
this.heartbeatTimer = setInterval(() => {
|
|
233
|
+
if (!this.connected) return;
|
|
234
|
+
this.request('ping', {}).catch(() => {});
|
|
235
|
+
}, this.heartbeatMs);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
_clearHeartbeat() {
|
|
239
|
+
if (this.heartbeatTimer) {
|
|
240
|
+
clearInterval(this.heartbeatTimer);
|
|
241
|
+
this.heartbeatTimer = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
_flushQueue() {
|
|
246
|
+
if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const queued = this.requestQueue.splice(0, this.requestQueue.length);
|
|
250
|
+
for (const item of queued) {
|
|
251
|
+
this._sendWithTimeout(item);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class PluginDispatcher {
|
|
2
|
+
constructor({ runtime }) {
|
|
3
|
+
this.runtime = runtime;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
async onTriggered(event) {
|
|
7
|
+
if (!this.runtime?.dispatchReply) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
await this.runtime.dispatchReply({
|
|
11
|
+
text: event?.payload?.message || '',
|
|
12
|
+
metadata: {
|
|
13
|
+
source: 'watch.triggered',
|
|
14
|
+
event
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|