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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-glance-plugin",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "OpenClaw plugin client for ticker-monitor openclaw-bridge",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -75,10 +75,10 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
75
75
  - 对应渠道配置(`emailConfig/callConfig/smsConfig`)
76
76
 
77
77
  渠道参数要求(必须):
78
- - 只要 `channels` 包含 `email`,必须提供 `emailConfig` 且包含 `to_address`
79
- - 只要 `channels` 包含 `call`,必须提供 `callConfig` 且包含 `phone`
80
- - 只要 `channels` 包含 `sms`,必须提供 `smsConfig` 且包含 `receiver`(或 `phone`)
81
- - 只要 `channels` 包含 `dingtalk`,必须提供 `dingtalkConfig` 且包含 `cas_id`
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`:必须提供手机号(`receiver` `phone`)
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`)在三个 CSV 中搜索标的名称或代码。
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`(或 `phone`)
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
- - `product_name`: 产品名称
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(可选,默认 90010,不需要修改)
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(可选,默认 3,不需要修改)
280
- - `msg_type`: 消息类型:text/markdown,默认 text
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
- return this._request('watch.create', payload);
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
- const requestId = makeRequestId();
175
- const msg = { type, request_id: requestId, payload: payload || {} };
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'], path.join(process.cwd(), '.openclaw-locks')))
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) {
@@ -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
- const requestId = makeRequestId();
93
- const msg = { type, request_id: requestId, payload };
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
  }