openclaw-glance-plugin 0.1.10 → 0.1.12
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/package.json
CHANGED
|
@@ -75,10 +75,10 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
75
75
|
- 对应渠道配置(`emailConfig/callConfig/smsConfig`)
|
|
76
76
|
|
|
77
77
|
渠道参数要求(必须):
|
|
78
|
-
- 只要 `channels` 包含 `email`,必须提供 `emailConfig
|
|
79
|
-
- 只要 `channels` 包含 `call`,必须提供 `callConfig
|
|
80
|
-
- 只要 `channels` 包含 `sms`,必须提供 `smsConfig
|
|
81
|
-
- 只要 `channels` 包含 `dingtalk`,必须提供 `dingtalkConfig
|
|
78
|
+
- 只要 `channels` 包含 `email`,必须提供 `emailConfig.to_address/template_id/title/content`
|
|
79
|
+
- 只要 `channels` 包含 `call`,必须提供 `callConfig.phone/customer_name/condition`
|
|
80
|
+
- 只要 `channels` 包含 `sms`,必须提供 `smsConfig.receiver(或phone)/template_id/content`
|
|
81
|
+
- 只要 `channels` 包含 `dingtalk`,必须提供 `dingtalkConfig.cas_id/template_id/msg_type/content`
|
|
82
82
|
|
|
83
83
|
成功判定:
|
|
84
84
|
- 返回 `success = true`
|
|
@@ -86,6 +86,8 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
86
86
|
失败处理:
|
|
87
87
|
- 明确返回失败原因,不要静默重试
|
|
88
88
|
- 提示用户补充或修正参数
|
|
89
|
+
- 若是超时/网络波动导致的重试,必须使用同一组创建参数再次调用 `watch.create`(不要改字段和值),避免重复创建策略
|
|
90
|
+
- `request_id` 由插件运行时自动生成并在同 payload 重试时自动复用;大模型无需手动设置 `request_id`
|
|
89
91
|
|
|
90
92
|
#### `watch.pause` / `watch.activate` / `watch.remove`
|
|
91
93
|
|
|
@@ -101,16 +103,18 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
101
103
|
#### `notify.sms` / `notify.call` / `notify.email` / `notify.dingtalk`
|
|
102
104
|
|
|
103
105
|
参数:
|
|
104
|
-
- `notify.sms
|
|
105
|
-
- `notify.call`:必须提供 `phone`
|
|
106
|
-
- `notify.email`:必须提供 `to_address`
|
|
107
|
-
- `notify.dingtalk`:必须提供 `cas_id`
|
|
106
|
+
- `notify.sms`:必须提供 `receiver`(或 `phone`)、`template_id`、`content`
|
|
107
|
+
- `notify.call`:必须提供 `phone`、`customer_name`、`condition`
|
|
108
|
+
- `notify.email`:必须提供 `to_address`、`template_id`、`title`、`content`
|
|
109
|
+
- `notify.dingtalk`:必须提供 `cas_id`、`template_id`、`msg_type`、`content`
|
|
108
110
|
|
|
109
111
|
成功判定:
|
|
110
112
|
- 返回 `success = true`
|
|
111
113
|
|
|
112
114
|
失败处理:
|
|
113
115
|
- 明确返回失败原因,不要静默重试
|
|
116
|
+
- 若是超时/网络波动导致的重试,必须使用同一组通知参数再次调用(不要改字段和值)
|
|
117
|
+
- `request_id` 由插件运行时自动生成并在同 payload 重试时自动复用;大模型无需手动设置 `request_id`
|
|
114
118
|
|
|
115
119
|
回执说明:
|
|
116
120
|
- 直连通知发送完成后,客户端会收到 `notify.sent` 事件(`overall_status/success_count/failed_count/deliveries`)
|
|
@@ -162,6 +166,7 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
162
166
|
- `data/stock_a.csv`:A股个股列表(`productType=stock`)
|
|
163
167
|
- `data/stock_hk.csv`:港股个股列表(`productType=hk_stock`)
|
|
164
168
|
- `data/index_a.csv`:A股指数列表(`productType=index`)
|
|
169
|
+
- `data/index_hk.csv`:港股指数列表(支持指数代码和中文名称查询)
|
|
165
170
|
|
|
166
171
|
### 场景1:用户只说股票简称/名称
|
|
167
172
|
- 使用模糊搜索在上述 CSV 中查找名称。
|
|
@@ -169,11 +174,12 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
169
174
|
- 用户确认后再创建策略。
|
|
170
175
|
|
|
171
176
|
### 场景2:不知道某个标的代码或所属市场
|
|
172
|
-
- 使用 `rg`(或 `grep
|
|
177
|
+
- 使用 `rg`(或 `grep`)在四个 CSV 中搜索标的名称或代码。
|
|
173
178
|
- 根据命中结果判断市场并映射 `productType`:
|
|
174
179
|
- A股个股 -> `stock`
|
|
175
180
|
- 港股个股 -> `hk_stock`
|
|
176
181
|
- A股指数 -> `index`
|
|
182
|
+
- 港股指数 -> `index`(`market=HK`)
|
|
177
183
|
- 若搜索结果不唯一或冲突,先向用户确认后再继续。
|
|
178
184
|
|
|
179
185
|
### 推荐检索命令
|
|
@@ -181,12 +187,14 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
181
187
|
```bash
|
|
182
188
|
# 按名称模糊查找(推荐)
|
|
183
189
|
rg -n "平安银行|腾讯|沪深300|BTC" data/stock_a.csv data/stock_hk.csv data/index_a.csv
|
|
190
|
+
rg -n "恒生科技指数|恒生指数|HSTECH|HSI" data/index_hk.csv
|
|
184
191
|
|
|
185
192
|
# 按代码查找
|
|
186
193
|
rg -n "000001|00700|399001" data/stock_a.csv data/stock_hk.csv data/index_a.csv
|
|
194
|
+
rg -n "HSTECH|HSI|VHSI" data/index_hk.csv
|
|
187
195
|
|
|
188
196
|
# grep 兜底(无 rg 时)
|
|
189
|
-
grep -nE "平安银行|腾讯|沪深300|000001|00700" data/stock_a.csv data/stock_hk.csv data/index_a.csv
|
|
197
|
+
grep -nE "平安银行|腾讯|沪深300|000001|00700|恒生科技指数|HSTECH" data/stock_a.csv data/stock_hk.csv data/index_a.csv data/index_hk.csv
|
|
190
198
|
```
|
|
191
199
|
|
|
192
200
|
## 行情查询(queryTickerData)
|
|
@@ -196,6 +204,7 @@ grep -nE "平安银行|腾讯|沪深300|000001|00700" data/stock_a.csv data/stoc
|
|
|
196
204
|
1. 先根据用户输入确定标的代码与市场:
|
|
197
205
|
- 如果是简称/名称,先在 `data/*.csv` 里模糊搜索并向用户确认候选。
|
|
198
206
|
- 如果是明确代码,按代码在 `data/*.csv` 查对应 `市场`。
|
|
207
|
+
- 港股指数可直接用代码(如 `HSTECH`)或中文名称(如 `恒生科技指数`)查询;命中 `index_hk.csv` 时优先使用 `market=HK`。
|
|
199
208
|
|
|
200
209
|
2. 调用已安装插件/包暴露的查询接口(例如 `queryTickerData`):
|
|
201
210
|
|
|
@@ -216,18 +225,17 @@ await runtime.queryTickerData({
|
|
|
216
225
|
`openclaw` 渠道必传,`email` / `call` / `sms` / `dingtalk` 可选。如用户没明确说明使用邮件(email)、电话/外呼(call)、短信(sms)、钉钉(dingtalk)通知提醒,则只需要传入`openclaw`渠道。
|
|
217
226
|
|
|
218
227
|
但一旦用户选择了某个通知渠道,其配置参数必须完整填写:
|
|
219
|
-
- 选择 `email` 必须提供 `emailConfig.to_address`
|
|
220
|
-
- 选择 `call` 必须提供 `callConfig.phone`
|
|
221
|
-
- 选择 `sms` 必须提供 `smsConfig.receiver
|
|
222
|
-
- 选择 `dingtalk` 必须提供 `dingtalkConfig.cas_id`
|
|
228
|
+
- 选择 `email` 必须提供 `emailConfig.to_address/template_id/title/content`
|
|
229
|
+
- 选择 `call` 必须提供 `callConfig.phone/customer_name/condition`
|
|
230
|
+
- 选择 `sms` 必须提供 `smsConfig.receiver(或phone)/template_id/content`
|
|
231
|
+
- 选择 `dingtalk` 必须提供 `dingtalkConfig.cas_id/template_id/msg_type/content`
|
|
223
232
|
|
|
224
233
|
### email 参数(emailConfig)
|
|
225
234
|
- `to_address`:收件人邮箱(必填,缺失不可创建/不可发送)
|
|
226
235
|
- `template_id`:邮件模板 ID(必填,默认为4,不需要修改)
|
|
227
236
|
- `template_params`:模板变量
|
|
228
|
-
- `title`:
|
|
229
|
-
- `
|
|
230
|
-
- `content`: 消息内容
|
|
237
|
+
- `title`: 收到邮件的标题(必填)
|
|
238
|
+
- `content`: 消息内容(必填)
|
|
231
239
|
示例:
|
|
232
240
|
```javascript
|
|
233
241
|
emailConfig: {
|
|
@@ -235,7 +243,6 @@ emailConfig: {
|
|
|
235
243
|
template_id: 4,
|
|
236
244
|
template_params: {
|
|
237
245
|
title: '监控提醒',
|
|
238
|
-
product_name: '比特币',
|
|
239
246
|
content: '测试消息1'
|
|
240
247
|
}
|
|
241
248
|
}
|
|
@@ -244,8 +251,8 @@ emailConfig: {
|
|
|
244
251
|
|
|
245
252
|
### call 参数(callConfig)
|
|
246
253
|
- `phone`:手机号(必填,缺失不可创建/不可发送)
|
|
247
|
-
- `customer_name
|
|
248
|
-
- `condition
|
|
254
|
+
- `customer_name`:客户名称(必填)
|
|
255
|
+
- `condition`:外呼内容(必填)
|
|
249
256
|
|
|
250
257
|
示例:
|
|
251
258
|
```javascript
|
|
@@ -260,8 +267,8 @@ callConfig: {
|
|
|
260
267
|
|
|
261
268
|
### sms 参数(smsConfig)
|
|
262
269
|
- `receiver`:手机号(必填,必须是纯数字;缺失不可创建/不可发送)
|
|
263
|
-
- `template_id`:短信模板 ID
|
|
264
|
-
- `content
|
|
270
|
+
- `template_id`:短信模板 ID(必填,默认 90010,不需要修改)
|
|
271
|
+
- `content`:短信变量内容(必填)
|
|
265
272
|
|
|
266
273
|
示例:
|
|
267
274
|
```javascript
|
|
@@ -276,9 +283,9 @@ smsConfig: {
|
|
|
276
283
|
|
|
277
284
|
### 钉钉 参数(dingtalkConfig)
|
|
278
285
|
- `cas_id`:钉钉用户ID(必填,缺失不可创建/不可发送)
|
|
279
|
-
- `template_id`:钉钉模板 ID
|
|
280
|
-
- `msg_type`:
|
|
281
|
-
- `content
|
|
286
|
+
- `template_id`:钉钉模板 ID(必填,默认 3,不需要修改)
|
|
287
|
+
- `msg_type`: 消息类型(必填):`text`/`markdown`
|
|
288
|
+
- `content`:消息内容(必填)
|
|
282
289
|
|
|
283
290
|
示例:
|
|
284
291
|
```javascript
|
|
@@ -298,6 +305,7 @@ dingtalkConfig: {
|
|
|
298
305
|
|------|-------------|------|------|
|
|
299
306
|
| A股个股 | stock | 000001 | 每3秒行情 |
|
|
300
307
|
| A股指数 | index | 000300 | 每3秒行情 |
|
|
308
|
+
| 港股指数 | index | HSTECH / 恒生科技指数 | 查询时 `market=HK` |
|
|
301
309
|
| 港股 | hk_stock | 00700 | 延迟15分钟 |
|
|
302
310
|
| 加密货币 | crypto | BTCUSDT | 每10秒行情 |
|
|
303
311
|
|
|
@@ -345,6 +353,12 @@ variables: { threshold: 420, product_name: '腾讯控股' }
|
|
|
345
353
|
- 明确返回失败原因给用户
|
|
346
354
|
- 引导用户补充或修正参数后再次创建
|
|
347
355
|
|
|
356
|
+
如果直连通知失败(`notify.send.result.success=false`):
|
|
357
|
+
- 优先读取并返回 `code/error/hint`,不要只说“通知失败”
|
|
358
|
+
- 若 `code=MISSING_REQUIRED_FIELD`,直接告诉用户缺失字段并让其补齐
|
|
359
|
+
- 若 `code=UNSUPPORTED_MESSAGE_TYPE`,提示“bridge 版本不支持 notify.send,需要升级并重启”
|
|
360
|
+
- 若 `code=UPSTREAM_UNAVAILABLE`,提示“notification 服务不可用或超时,请稍后重试”
|
|
361
|
+
|
|
348
362
|
## 相关资源
|
|
349
363
|
|
|
350
364
|
- 市场参考: [references/markets.md](references/markets.md)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
类型,代码,名称,完整代码,市场
|
|
2
|
+
港股指数,VHSI,恒指波幅指数,VHSI,HK
|
|
3
|
+
港股指数,HSTECH,恒生科技指数,HSTECH,HK
|
|
4
|
+
港股指数,H11144,HKT内地消费指数,H11144,HK
|
|
5
|
+
港股指数,H11140,香港红利指数,H11140,HK
|
|
6
|
+
港股指数,HSMOGI,恒生内地石油,HSMOGI,HK
|
|
7
|
+
港股指数,CES120,中华120指数,CES120,HK
|
|
8
|
+
港股指数,CESHKM,中华香港内地指数,CESHKM,HK
|
|
9
|
+
港股指数,SCCEA,沪深港通中国企业,SCCEA,HK
|
|
10
|
+
港股指数,HSMBI,恒生内地银行,HSMBI,HK
|
|
11
|
+
港股指数,HSCEI,国企指数,HSCEI,HK
|
|
12
|
+
港股指数,H11153,内地国企指数,H11153,HK
|
|
13
|
+
港股指数,H11123,香港内地股指数,H11123,HK
|
|
14
|
+
港股指数,CESA80,中华A80指数,CESA80,HK
|
|
15
|
+
港股指数,HSC,恒生工商指数,HSC,HK
|
|
16
|
+
港股指数,H11152,内地民企指数,H11152,HK
|
|
17
|
+
港股指数,HSCEESG,恒生国指ESG指数,HSCEESG,HK
|
|
18
|
+
港股指数,SPHKL,香港大型股指数,SPHKL,HK
|
|
19
|
+
港股指数,CES300,中华沪港通300,CES300,HK
|
|
20
|
+
港股指数,CES280,中华280指数,CES280,HK
|
|
21
|
+
港股指数,CESCPD,中华内房股,CESCPD,HK
|
|
22
|
+
港股指数,H11143,HKT内地地产指数,H11143,HK
|
|
23
|
+
港股指数,HSCCI,恒生红筹指数,HSCCI,HK
|
|
24
|
+
港股指数,H11110,香港基本面50指数,H11110,HK
|
|
25
|
+
港股指数,SPHKG,香港创业板指数,SPHKG,HK
|
|
26
|
+
港股指数,CESP50,中华港股通优选50,CESP50,HK
|
|
27
|
+
港股指数,HSI,恒生指数,HSI,HK
|
|
28
|
+
港股指数,H11100,香港100指数,H11100,HK
|
|
29
|
+
港股指数,CESFHY,中华预期高息股,CESFHY,HK
|
|
30
|
+
港股指数,HSU,恒生公用事业指数,HSU,HK
|
|
31
|
+
港股指数,H11120,香港中盘精选指数,H11120,HK
|
|
32
|
+
港股指数,HSIESG,恒指ESG指数,HSIESG,HK
|
|
33
|
+
港股指数,HSMPI,恒生内地地产,HSMPI,HK
|
|
34
|
+
港股指数,CES100,港股通精选100,CES100,HK
|
|
35
|
+
港股指数,HSBIO,恒生生物科技指数,HSBIO,HK
|
|
36
|
+
港股指数,HSIESGS,恒指ESG增强指数,HSIESGS,HK
|
|
37
|
+
港股指数,HSF,恒生金融分类指数,HSF,HK
|
|
38
|
+
港股指数,CESHKB,中华香港生物科技,CESHKB,HK
|
|
39
|
+
港股指数,HSP,恒生地产分类指数,HSP,HK
|
|
40
|
+
港股指数,CESG10,中华博彩指数,CESG10,HK
|
|
@@ -11,6 +11,32 @@ function makeRequestId() {
|
|
|
11
11
|
return `req_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function stableStringify(value) {
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
|
17
|
+
}
|
|
18
|
+
if (value && typeof value === 'object') {
|
|
19
|
+
const keys = Object.keys(value).sort();
|
|
20
|
+
return `{${keys
|
|
21
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
22
|
+
.join(',')}}`;
|
|
23
|
+
}
|
|
24
|
+
return JSON.stringify(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const IDEMPOTENT_REQUEST_TYPES = new Set(['watch.create', 'notify.send']);
|
|
28
|
+
const IDEMPOTENT_RESULT_TYPE_TO_REQUEST_TYPE = new Map([
|
|
29
|
+
['watch.create.result', 'watch.create'],
|
|
30
|
+
['notify.send.result', 'notify.send']
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function idempotentRequestFingerprint(payload = {}) {
|
|
34
|
+
const normalized = { ...(payload || {}) };
|
|
35
|
+
delete normalized.request_id;
|
|
36
|
+
delete normalized.requestId;
|
|
37
|
+
return stableStringify(normalized);
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
/**
|
|
15
41
|
* 全局单例实例
|
|
16
42
|
* @type {OpenClawBridgeClient|null}
|
|
@@ -86,6 +112,10 @@ export class OpenClawBridgeClient extends EventEmitter {
|
|
|
86
112
|
this.reconnectAttempt = 0;
|
|
87
113
|
this.pending = new Map();
|
|
88
114
|
this.requestQueue = [];
|
|
115
|
+
this.idempotentRetryWindowMs = 5 * 60 * 1000;
|
|
116
|
+
this.idempotentMaxCacheEntries = 1000;
|
|
117
|
+
this.idempotentRequestCache = new Map();
|
|
118
|
+
this.idempotentFingerprintByRequestId = new Map();
|
|
89
119
|
}
|
|
90
120
|
|
|
91
121
|
get wsUrl() {
|
|
@@ -122,7 +152,8 @@ export class OpenClawBridgeClient extends EventEmitter {
|
|
|
122
152
|
}
|
|
123
153
|
|
|
124
154
|
async createWatch(payload) {
|
|
125
|
-
|
|
155
|
+
const resolved = this._resolveIdempotentRequest('watch.create', payload || {});
|
|
156
|
+
return this._request('watch.create', resolved.payload, { requestId: resolved.requestId });
|
|
126
157
|
}
|
|
127
158
|
|
|
128
159
|
async activateWatch(strategyId) {
|
|
@@ -170,9 +201,18 @@ export class OpenClawBridgeClient extends EventEmitter {
|
|
|
170
201
|
});
|
|
171
202
|
}
|
|
172
203
|
|
|
173
|
-
async _request(type, payload) {
|
|
174
|
-
|
|
175
|
-
|
|
204
|
+
async _request(type, payload, options = {}) {
|
|
205
|
+
let requestId = options.requestId || '';
|
|
206
|
+
let normalizedPayload = payload || {};
|
|
207
|
+
if (!requestId && IDEMPOTENT_REQUEST_TYPES.has(type)) {
|
|
208
|
+
const resolved = this._resolveIdempotentRequest(type, normalizedPayload);
|
|
209
|
+
requestId = resolved.requestId;
|
|
210
|
+
normalizedPayload = resolved.payload;
|
|
211
|
+
}
|
|
212
|
+
if (!requestId) {
|
|
213
|
+
requestId = makeRequestId();
|
|
214
|
+
}
|
|
215
|
+
const msg = { type, request_id: requestId, payload: normalizedPayload };
|
|
176
216
|
const { promise, resolve, reject } = this._buildWaiter(type, requestId);
|
|
177
217
|
|
|
178
218
|
if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
@@ -272,6 +312,7 @@ export class OpenClawBridgeClient extends EventEmitter {
|
|
|
272
312
|
const waiter = this.pending.get(requestId);
|
|
273
313
|
this.pending.delete(requestId);
|
|
274
314
|
clearTimeout(waiter.timer);
|
|
315
|
+
this._finalizeIdempotentRequest(msg, requestId);
|
|
275
316
|
waiter.resolve(msg);
|
|
276
317
|
return;
|
|
277
318
|
}
|
|
@@ -361,4 +402,74 @@ export class OpenClawBridgeClient extends EventEmitter {
|
|
|
361
402
|
this.heartbeatTimer = null;
|
|
362
403
|
}
|
|
363
404
|
}
|
|
405
|
+
|
|
406
|
+
_resolveIdempotentRequest(type, payload = {}) {
|
|
407
|
+
this._cleanupIdempotentRequestCache();
|
|
408
|
+
const normalizedPayload = { ...(payload || {}) };
|
|
409
|
+
let requestId = String(
|
|
410
|
+
normalizedPayload.request_id || normalizedPayload.requestId || ''
|
|
411
|
+
).trim();
|
|
412
|
+
|
|
413
|
+
if (!requestId) {
|
|
414
|
+
const fingerprint = idempotentRequestFingerprint(normalizedPayload);
|
|
415
|
+
const cacheKey = `${type}:${fingerprint}`;
|
|
416
|
+
const cached = this.idempotentRequestCache.get(cacheKey);
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
if (cached && now - cached.ts <= this.idempotentRetryWindowMs) {
|
|
419
|
+
requestId = cached.requestId;
|
|
420
|
+
} else {
|
|
421
|
+
requestId = makeRequestId();
|
|
422
|
+
this.idempotentRequestCache.set(cacheKey, { requestId, ts: now });
|
|
423
|
+
}
|
|
424
|
+
this.idempotentFingerprintByRequestId.set(requestId, cacheKey);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
normalizedPayload.request_id = requestId;
|
|
428
|
+
return { requestId, payload: normalizedPayload };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
_cleanupIdempotentRequestCache(now = Date.now()) {
|
|
432
|
+
for (const [cacheKey, entry] of this.idempotentRequestCache.entries()) {
|
|
433
|
+
if (!entry || now - entry.ts > this.idempotentRetryWindowMs) {
|
|
434
|
+
this.idempotentRequestCache.delete(cacheKey);
|
|
435
|
+
if (entry?.requestId) {
|
|
436
|
+
this.idempotentFingerprintByRequestId.delete(entry.requestId);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (this.idempotentRequestCache.size > this.idempotentMaxCacheEntries) {
|
|
442
|
+
const sorted = Array.from(this.idempotentRequestCache.entries()).sort(
|
|
443
|
+
(a, b) => (a[1]?.ts || 0) - (b[1]?.ts || 0)
|
|
444
|
+
);
|
|
445
|
+
const overflow = this.idempotentRequestCache.size - this.idempotentMaxCacheEntries;
|
|
446
|
+
for (let i = 0; i < overflow; i += 1) {
|
|
447
|
+
const [cacheKey, entry] = sorted[i] || [];
|
|
448
|
+
if (!cacheKey) continue;
|
|
449
|
+
this.idempotentRequestCache.delete(cacheKey);
|
|
450
|
+
if (entry?.requestId) {
|
|
451
|
+
this.idempotentFingerprintByRequestId.delete(entry.requestId);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
for (const [requestId, cacheKey] of this.idempotentFingerprintByRequestId.entries()) {
|
|
457
|
+
if (!this.idempotentRequestCache.has(cacheKey)) {
|
|
458
|
+
this.idempotentFingerprintByRequestId.delete(requestId);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
_finalizeIdempotentRequest(msg, requestId) {
|
|
464
|
+
const requestType = IDEMPOTENT_RESULT_TYPE_TO_REQUEST_TYPE.get(msg?.type);
|
|
465
|
+
if (!requestType) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const cacheKey = this.idempotentFingerprintByRequestId.get(requestId);
|
|
469
|
+
if (!cacheKey || !cacheKey.startsWith(`${requestType}:`)) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.idempotentFingerprintByRequestId.delete(requestId);
|
|
473
|
+
this.idempotentRequestCache.delete(cacheKey);
|
|
474
|
+
}
|
|
364
475
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import process from 'node:process';
|
|
3
4
|
|
|
@@ -15,13 +16,32 @@ function pick(source, keys, fallback = undefined) {
|
|
|
15
16
|
return fallback;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
export function resolveDefaultLockDir({ env = process.env, cwd = process.cwd() } = {}) {
|
|
20
|
+
const xdgStateHome = pick(env, ['XDG_STATE_HOME']);
|
|
21
|
+
if (xdgStateHome) {
|
|
22
|
+
return path.join(String(xdgStateHome), 'openclaw-locks');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const homeDir = pick(env, ['HOME', 'USERPROFILE']);
|
|
26
|
+
if (homeDir) {
|
|
27
|
+
return path.join(String(homeDir), '.openclaw-locks');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cwdRoot = path.parse(cwd).root;
|
|
31
|
+
if (cwd === cwdRoot) {
|
|
32
|
+
return path.join(os.tmpdir(), 'openclaw-locks');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return path.join(cwd, '.openclaw-locks');
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
export function resolveRuntimeConfig({ env = process.env, pluginConfig = {} } = {}) {
|
|
19
39
|
const baseWsUrl = String(
|
|
20
40
|
pick(pluginConfig, ['baseWsUrl', 'base_ws_url'], pick(env, ['OPENCLAW_BASE_WS_URL'], DEFAULT_BASE_WS_URL))
|
|
21
41
|
);
|
|
22
42
|
const token = String(pick(pluginConfig, ['token'], pick(env, ['OPENCLAW_WS_TOKEN'], '')));
|
|
23
43
|
const lockDir = String(
|
|
24
|
-
pick(pluginConfig, ['lockDir', 'lock_dir'], pick(env, ['OPENCLAW_LOCK_DIR'],
|
|
44
|
+
pick(pluginConfig, ['lockDir', 'lock_dir'], pick(env, ['OPENCLAW_LOCK_DIR'], resolveDefaultLockDir({ env })))
|
|
25
45
|
);
|
|
26
46
|
const lockKey = ProcessLock.buildLockKey(baseWsUrl, token);
|
|
27
47
|
if (!token) {
|
package/src/plugin/index.js
CHANGED
|
@@ -24,6 +24,9 @@ export async function startPluginRuntime({ runtime, pluginConfig } = {}) {
|
|
|
24
24
|
return activeRuntime;
|
|
25
25
|
}
|
|
26
26
|
const config = resolveRuntimeConfig({ pluginConfig });
|
|
27
|
+
runtime?.logger?.info?.(
|
|
28
|
+
`[openclaw-glance-plugin] runtime config resolved: baseWsUrl=${config.baseWsUrl}, lockDir=${config.lockDir}`
|
|
29
|
+
);
|
|
27
30
|
const lock = new ProcessLock({
|
|
28
31
|
lockDir: config.lockDir,
|
|
29
32
|
key: config.lockKey
|
|
@@ -9,6 +9,32 @@ function makeRequestId() {
|
|
|
9
9
|
return `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function stableStringify(value) {
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
|
15
|
+
}
|
|
16
|
+
if (value && typeof value === 'object') {
|
|
17
|
+
const keys = Object.keys(value).sort();
|
|
18
|
+
return `{${keys
|
|
19
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
|
|
20
|
+
.join(',')}}`;
|
|
21
|
+
}
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const IDEMPOTENT_REQUEST_TYPES = new Set(['watch.create', 'notify.send']);
|
|
26
|
+
const IDEMPOTENT_RESULT_TYPE_TO_REQUEST_TYPE = new Map([
|
|
27
|
+
['watch.create.result', 'watch.create'],
|
|
28
|
+
['notify.send.result', 'notify.send']
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function idempotentRequestFingerprint(payload = {}) {
|
|
32
|
+
const normalized = { ...(payload || {}) };
|
|
33
|
+
delete normalized.request_id;
|
|
34
|
+
delete normalized.requestId;
|
|
35
|
+
return stableStringify(normalized);
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
export class BridgeRuntime extends EventEmitter {
|
|
13
39
|
constructor({
|
|
14
40
|
baseWsUrl,
|
|
@@ -38,6 +64,8 @@ export class BridgeRuntime extends EventEmitter {
|
|
|
38
64
|
this.reconnectMaxMs = reconnectMaxMs;
|
|
39
65
|
this.enqueueIfDisconnected = enqueueIfDisconnected;
|
|
40
66
|
this.maxQueueSize = maxQueueSize;
|
|
67
|
+
this.idempotentRetryWindowMs = 5 * 60 * 1000;
|
|
68
|
+
this.idempotentMaxCacheEntries = 1000;
|
|
41
69
|
|
|
42
70
|
this.ws = null;
|
|
43
71
|
this.connected = false;
|
|
@@ -46,6 +74,8 @@ export class BridgeRuntime extends EventEmitter {
|
|
|
46
74
|
this.heartbeatTimer = null;
|
|
47
75
|
this.pending = new Map();
|
|
48
76
|
this.requestQueue = [];
|
|
77
|
+
this.idempotentRequestCache = new Map();
|
|
78
|
+
this.idempotentFingerprintByRequestId = new Map();
|
|
49
79
|
}
|
|
50
80
|
|
|
51
81
|
get wsUrl() {
|
|
@@ -89,8 +119,14 @@ export class BridgeRuntime extends EventEmitter {
|
|
|
89
119
|
}
|
|
90
120
|
|
|
91
121
|
async request(type, payload = {}) {
|
|
92
|
-
|
|
93
|
-
|
|
122
|
+
let requestId = makeRequestId();
|
|
123
|
+
let normalizedPayload = payload || {};
|
|
124
|
+
if (IDEMPOTENT_REQUEST_TYPES.has(type)) {
|
|
125
|
+
const resolved = this._resolveIdempotentRequest(type, normalizedPayload);
|
|
126
|
+
requestId = resolved.requestId;
|
|
127
|
+
normalizedPayload = resolved.payload;
|
|
128
|
+
}
|
|
129
|
+
const msg = { type, request_id: requestId, payload: normalizedPayload };
|
|
94
130
|
const { promise, resolve, reject } = this._buildWaiter();
|
|
95
131
|
|
|
96
132
|
if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
@@ -190,6 +226,7 @@ export class BridgeRuntime extends EventEmitter {
|
|
|
190
226
|
const waiter = this.pending.get(requestId);
|
|
191
227
|
this.pending.delete(requestId);
|
|
192
228
|
clearTimeout(waiter.timer);
|
|
229
|
+
this._finalizeIdempotentRequest(msg, requestId);
|
|
193
230
|
waiter.resolve(msg);
|
|
194
231
|
return;
|
|
195
232
|
}
|
|
@@ -251,4 +288,75 @@ export class BridgeRuntime extends EventEmitter {
|
|
|
251
288
|
this._sendWithTimeout(item);
|
|
252
289
|
}
|
|
253
290
|
}
|
|
291
|
+
|
|
292
|
+
_resolveIdempotentRequest(type, payload = {}) {
|
|
293
|
+
this._cleanupIdempotentRequestCache();
|
|
294
|
+
const normalizedPayload = { ...(payload || {}) };
|
|
295
|
+
let requestId = String(
|
|
296
|
+
normalizedPayload.request_id || normalizedPayload.requestId || ''
|
|
297
|
+
).trim();
|
|
298
|
+
|
|
299
|
+
if (!requestId) {
|
|
300
|
+
const fingerprint = idempotentRequestFingerprint(normalizedPayload);
|
|
301
|
+
const cacheKey = `${type}:${fingerprint}`;
|
|
302
|
+
const cached = this.idempotentRequestCache.get(cacheKey);
|
|
303
|
+
const now = Date.now();
|
|
304
|
+
if (cached && now - cached.ts <= this.idempotentRetryWindowMs) {
|
|
305
|
+
requestId = cached.requestId;
|
|
306
|
+
} else {
|
|
307
|
+
requestId = makeRequestId();
|
|
308
|
+
this.idempotentRequestCache.set(cacheKey, { requestId, ts: now });
|
|
309
|
+
}
|
|
310
|
+
this.idempotentFingerprintByRequestId.set(requestId, cacheKey);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
normalizedPayload.request_id = requestId;
|
|
314
|
+
return { requestId, payload: normalizedPayload };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_cleanupIdempotentRequestCache(now = Date.now()) {
|
|
318
|
+
for (const [cacheKey, entry] of this.idempotentRequestCache.entries()) {
|
|
319
|
+
if (!entry || now - entry.ts > this.idempotentRetryWindowMs) {
|
|
320
|
+
this.idempotentRequestCache.delete(cacheKey);
|
|
321
|
+
if (entry?.requestId) {
|
|
322
|
+
this.idempotentFingerprintByRequestId.delete(entry.requestId);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (this.idempotentRequestCache.size > this.idempotentMaxCacheEntries) {
|
|
328
|
+
const sorted = Array.from(this.idempotentRequestCache.entries()).sort(
|
|
329
|
+
(a, b) => (a[1]?.ts || 0) - (b[1]?.ts || 0)
|
|
330
|
+
);
|
|
331
|
+
const overflow = this.idempotentRequestCache.size - this.idempotentMaxCacheEntries;
|
|
332
|
+
for (let i = 0; i < overflow; i += 1) {
|
|
333
|
+
const [cacheKey, entry] = sorted[i] || [];
|
|
334
|
+
if (!cacheKey) continue;
|
|
335
|
+
this.idempotentRequestCache.delete(cacheKey);
|
|
336
|
+
if (entry?.requestId) {
|
|
337
|
+
this.idempotentFingerprintByRequestId.delete(entry.requestId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const [requestId, cacheKey] of this.idempotentFingerprintByRequestId.entries()) {
|
|
343
|
+
if (!this.idempotentRequestCache.has(cacheKey)) {
|
|
344
|
+
this.idempotentFingerprintByRequestId.delete(requestId);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_finalizeIdempotentRequest(msg, requestId) {
|
|
350
|
+
const requestType = IDEMPOTENT_RESULT_TYPE_TO_REQUEST_TYPE.get(msg?.type);
|
|
351
|
+
if (!requestType) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const cacheKey = this.idempotentFingerprintByRequestId.get(requestId);
|
|
355
|
+
if (!cacheKey || !cacheKey.startsWith(`${requestType}:`)) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// 收到明确回包后结束本次重试窗口;仅“超时无回包”保留复用 request_id。
|
|
359
|
+
this.idempotentFingerprintByRequestId.delete(requestId);
|
|
360
|
+
this.idempotentRequestCache.delete(cacheKey);
|
|
361
|
+
}
|
|
254
362
|
}
|