openclaw-glance-plugin 0.1.16 → 0.1.21

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.16",
3
+ "version": "0.1.21",
4
4
  "description": "OpenClaw plugin client for ticker-monitor openclaw-bridge",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -3,31 +3,19 @@ name: glance-watch
3
3
  description: 智能盯盘插件,用于监控A股、港股、比特币等金融市场行情并在条件触发时发送提醒。当用户要求盯盘、监控价格、设置提醒、需要通过邮件/电话/短信/钉钉发起通知时自动触发,例如"帮我盯着比特币"、监控某只股票、涨跌幅提醒、短信通知我等。
4
4
  ---
5
5
 
6
- # Glance Watch 智能盯盘
6
+ # Glance Watch 智能盯盘(主入口)
7
7
 
8
- ## 快速开始
8
+ ## 目标
9
9
 
10
- 1. **前置条件**(已完成):
11
- - 插件/包已安装并可用(由宿主环境预先完成)
12
- - 宿主已启动插件运行时(长连接由插件后台维护)
10
+ 用最小上下文完成三类任务:
11
+ - 查行情:`watch.query_ticker`
12
+ - 建/管策略:`watch.create` / `watch.list` / `watch.pause` / `watch.activate` / `watch.remove`
13
+ - 立即通知:`notify.sms` / `notify.call` / `notify.email` / `notify.dingtalk`
13
14
 
14
- 2. **用户请求盯盘时**,解析用户需求提取:
15
- - `product_code`: 产品代码
16
- - `product_type`: 市场类型 (stock/index/hk_stock/crypto)
17
- - `operator_type`: 固定为 `rule`
18
- - `operator_parameters.condition`: 条件表达式
19
- - `operator_parameters.variables`: 变量值
20
-
21
- 3. **通过已安装运行时提交盯盘请求**(长连接由宿主运行时维护)
22
- 4. **用户要求“查行情/看当前价格/报价”时**,优先调用 `queryTickerData` 获取实时数据,再决定是否创建盯盘策略。
23
- 5. **用户要求“发短信/打电话/发邮件/发钉钉”时**,调用对应 `notify.*` 动作直接发送。
24
-
25
- ## 调用契约(必须遵循)
15
+ ## 调用契约(硬约束)
26
16
 
27
17
  ### 统一动作名
28
18
 
29
- 宿主需将已安装插件/包接口映射为以下动作名(建议保持一致):
30
-
31
19
  - `watch.query_ticker`
32
20
  - `watch.create`
33
21
  - `watch.list`
@@ -41,442 +29,71 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
41
29
 
42
30
  ### 调用顺序
43
31
 
44
- 1. 用户是“查行情”意图:先调用 `watch.query_ticker`
45
- 2. 用户是“盯盘创建”意图:先补齐参数后调用 `watch.create`
46
- 3. 用户是“暂停/恢复/删除”意图:分别调用 `watch.pause` / `watch.activate` / `watch.remove`
47
- 4. 用户是“查看策略/查看我的策略/看 active paused completed 策略”意图:调用 `watch.list`
48
- 5. 用户是“立即发短信/打电话/发邮件/发钉钉”意图:调用 `notify.sms` / `notify.call` / `notify.email` / `notify.dingtalk`
49
-
50
- 禁止跳步:创建盯盘前若缺关键字段必须先追问。
51
-
52
- ### 动作参数与成功判定
32
+ 1. 用户问“现在多少钱/查行情” -> 先 `watch.query_ticker`
33
+ 2. 用户要“盯盘/提醒” -> 补齐参数后 `watch.create`
34
+ 3. 用户要“看我的策略” -> `watch.list`
35
+ 4. 用户要“暂停/恢复/删除” -> `watch.pause` / `watch.activate` / `watch.remove`
36
+ 5. 用户要“马上发通知” -> 对应 `notify.*`
53
37
 
54
- #### `watch.query_ticker`
38
+ ### 创建策略最小必填
55
39
 
56
- 参数:
57
- - `stockCode`(或 `productCode`)
58
- - `productType`
59
- - `market`(`crypto` 可传空字符串)
60
-
61
- 成功判定:
62
- - 返回 `code = "000000"` 或 `success = true`
63
-
64
- 失败处理:
65
- - 返回失败原因
66
- - 引导用户确认代码/市场后重试
67
-
68
- #### `watch.create`
69
-
70
- 参数(最少):
71
40
  - `product_code`
72
- - `product_type`
73
- - `operator_type`
74
- - `operator_parameters`
75
-
76
- 建议附加:
77
- - `channels`(默认至少包含 `openclaw`)
78
- - 对应渠道配置(`channel_configs.email/call/sms/dingtalk`)
79
-
80
- 固定模板(必须按此结构构造,字段名不要改):
81
-
82
- ```javascript
83
- {
84
- product_code: 'BTCUSDT',
85
- product_type: 'crypto',
86
- operator_type: 'rule', // 必须是 rule,不可改成条件表达式
87
- operator_parameters: {
88
- condition: 'change_percent <= cp_threshold',
89
- variables: {
90
- cp_threshold: -0.02,
91
- product_name: '比特币'
92
- }
93
- },
94
- channels: ['openclaw', 'dingtalk', 'sms'],
95
- // 注意:必须是对象,不要传 JSON 字符串
96
- channel_configs: {
97
- openclaw: {},
98
- dingtalk: {
99
- cas_id: 'jinguo.xie',
100
- template_id: 3,
101
- msg_type: 'text',
102
- content: '比特币跌幅超2%!当前价格 ${price},跌幅 ${change_percent}%。建议卖出!'
103
- },
104
- sms: {
105
- receiver: '18616726853',
106
- template_id: 90010,
107
- content: '比特币跌幅超2%!当前价格 ${price},建议卖出!'
108
- }
109
- }
110
- }
111
- ```
112
-
113
- 禁止项(任何一条命中都必须先修正再调用):
114
- - `operator_type` 不是 `rule`(例如写成 `change_percent <=`)
115
- - 把 `condition` 放在顶层,而不是 `operator_parameters.condition`
116
- - 把 `channel_configs` 下的渠道配置传成 JSON 字符串
117
- - 用户未要求某渠道,却默认附加该渠道配置
118
-
119
- 渠道参数要求(必须):
120
- - 只要 `channels` 包含 `email`,必须提供 `channel_configs.email.to_address/template_id/title/content`
121
- - 只要 `channels` 包含 `call`,必须提供 `channel_configs.call.phone/customer_name/condition`
122
- - 只要 `channels` 包含 `sms`,必须提供 `channel_configs.sms.receiver(或phone)/template_id/content`
123
- - 只要 `channels` 包含 `dingtalk`,必须提供 `channel_configs.dingtalk.cas_id/template_id/msg_type/content`
124
-
125
- 成功判定:
126
- - 返回 `success = true`
127
-
128
- 失败处理:
129
- - 明确返回失败原因,不要静默重试
130
- - 提示用户补充或修正参数
131
- - 若是超时/网络波动导致的重试,必须使用同一组创建参数再次调用 `watch.create`(不要改字段和值),避免重复创建策略
132
- - `request_id` 由插件运行时自动生成并在同 payload 重试时自动复用;大模型无需手动设置 `request_id`
133
-
134
- 小模型(7B)执行策略(强约束):
135
- 1. 先用固定模板生成 payload 骨架,不要自由发挥字段名
136
- 2. 仅替换值:`product_code/product_type/operator_parameters/channels/channel_configs`
137
- 3. 发送前逐条自检“禁止项”
138
- 4. 若失败返回 `400` 且提示“未注册的算子类型”,立即把 `operator_type` 纠正为 `rule` 并重试
139
-
140
- #### `watch.pause` / `watch.activate` / `watch.remove`
141
-
142
- 参数:
143
- - `strategyId`(或 `strategy_id`)
144
-
145
- 成功判定:
146
- - 返回 `success = true`
147
-
148
- 失败处理:
149
- - 返回失败原因并提示用户确认策略 ID
150
-
151
- #### `watch.list`
152
-
153
- 参数(可选):
154
- - `status`:策略状态过滤。可传 `active` / `paused` / `completed` / `failed` / `expired`;不传表示查询该用户全部策略
155
- - `product_code`(或 `productCode`):按标的代码过滤
156
-
157
- 成功判定:
158
- - 返回 `success = true`
159
- - `data.total` 为命中策略数,`data.strategies` 为策略列表
160
-
161
- 失败处理:
162
- - 返回失败原因,不要静默重试
163
- - 若筛选条件为空结果,明确告知“当前条件下没有策略”
164
-
165
- 安全约束(必须):
166
- - `watch.list` 只能查询当前连接用户自己的策略
167
- - 不要尝试通过参数传 `user_id` / `use_id` 越权查询
168
-
169
- #### `notify.sms` / `notify.call` / `notify.email` / `notify.dingtalk`
170
-
171
- 参数:
172
- - `notify.sms`:必须提供 `receiver`(或 `phone`)、`template_id`、`content`
173
- - `notify.call`:必须提供 `phone`、`customer_name`、`condition`
174
- - `notify.email`:必须提供 `to_address`、`template_id`、`title`、`content`
175
- - `notify.dingtalk`:必须提供 `cas_id`、`template_id`、`msg_type`、`content`
176
-
177
- 固定模板(必须按此结构,不要增删字段名):
178
-
179
- ```javascript
180
- // notify.sms
181
- { receiver: '13800138000', template_id: 90010, content: '测试消息1' }
182
-
183
- // notify.call
184
- { phone: '13800138000', customer_name: 'Demo', condition: '比特币跌幅超过2%' }
185
-
186
- // notify.email
187
- { to_address: 'demo@example.com', template_id: 4, title: '监控提醒', content: '测试消息1' }
188
-
189
- // notify.dingtalk
190
- { cas_id: 'user.dingtalk', template_id: 3, msg_type: 'text', content: '测试消息1' }
191
- ```
192
-
193
- 禁止项(任何一条命中都必须先修正再调用):
194
- - 把手机号写成非数字字符串(空格、`+86-`、中划线等)
195
- - 把 `template_id` 写成字符串(应传数字)
196
- - `msg_type` 传非 `text/markdown`
197
- - 传入 `request_id`(通知请求由插件运行时自动生成并复用)
198
-
199
- 成功判定:
200
- - 返回 `success = true`
201
-
202
- 失败处理:
203
- - 明确返回失败原因,不要静默重试
204
- - 若是超时/网络波动导致的重试,必须使用同一组通知参数再次调用(不要改字段和值)
205
- - `request_id` 由插件运行时自动生成并在同 payload 重试时自动复用;大模型无需手动设置 `request_id`
206
- - 若返回缺字段错误(如 `MISSING_REQUIRED_FIELD`),只补缺失字段,其他字段保持不变再重试
207
-
208
- 回执说明:
209
- - 直连通知发送完成后,客户端会收到 `notify.sent` 事件(`overall_status/success_count/failed_count/deliveries`)
210
-
211
- 离线补发识别(`watch.triggered`):
212
- - 若事件包含 `delivery_mode = "offline_replay"` 或 `replayed = true`,表示这是用户离线期间触发后补发的消息
213
- - `trigger_time` 表示原始触发时间,`replayed_at` 表示补发时间
214
- - 向用户描述时应明确区分:例如“这条是离线期间触发,现已补发到当前会话”
215
-
216
- ## 调用判定规则
217
-
218
- 只有在用户明确表达以下意图时调用插件:
219
- - “帮我盯盘/监控/提醒”
220
- - “涨到/跌到某个价格提醒我”
221
- - “达到某个涨跌幅提醒我”
222
-
223
- 调用前必须确认:
224
- - `product_code`(标的代码)
225
41
  - `product_type`(`stock/index/hk_stock/crypto`)
226
- - `operator_parameters.condition`(规则表达式)
227
- - `operator_parameters.variables`(阈值变量)
228
-
229
- 缺任一项时先追问,不要猜测阈值。
230
-
231
- ### 调用前最终检查(小尺寸模型 必做)
232
-
233
- 在实际调用工具前,逐条检查:
234
- 1. `watch.create` 是否使用 `snake_case`:`product_code/product_type/operator_type/operator_parameters/channel_configs`
235
- 2. `operator_type` 是否固定为 `rule`
236
- 3. `operator_parameters.condition` 与 `operator_parameters.variables` 是否都存在
237
- 4. `channels` 是否与 `channel_configs` 一一对应(选了哪个渠道就必须有哪个配置)
238
- 5. 所有配置是否为对象而非 JSON 字符串
239
- 6. `request_id` 默认不手动传;由插件自动生成并在同 payload 重试时复用。仅当宿主框架明确要求外部指定时才传,并且重试必须保持不变
240
-
241
- ### 买卖意图与条件方向
242
-
243
- 用户设置价格提醒时,往往不会说"大于等于"或"小于等于",而是说"到了XX提醒我"。此时需要判断用户的**买卖意图**来决定条件方向:
244
-
245
- | 用户意图 | 条件方向 | 说明 |
246
- |---------|---------|------|
247
- | 想买入(逢低买入) | `price <= threshold` | 价格**跌到**目标价时提醒,抄底机会 |
248
- | 想卖出(止盈/止损) | `price >= threshold` | 价格**涨到**目标价时提醒,落袋为安 |
249
-
250
- **判断流程:**
251
-
252
- 1. 如果用户明确说了方向(如"涨到XX"、"跌到XX"),直接使用对应条件
253
- 2. 如果用户只说"到了XX提醒我",**必须追问一句**:
254
- - "你是想在价格涨到XX时卖出,还是跌到XX时买入?"
255
- - 或者更简洁地问:"这个是准备买还是卖?买的话我帮你盯跌到XX,卖的话盯涨到XX"
256
- 3. 根据用户回答设置条件:
257
- - 买入 → `price <= threshold`
258
- - 卖出 → `price >= threshold`
259
-
260
- **常见表达映射:**
261
- - "涨到/涨过/突破/冲到" → `price >= threshold`(卖出方向)
262
- - "跌到/跌破/回调到/回到" → `price <= threshold`(买入方向)
263
- - "到了/到达/价格到" → **方向不明确,需追问买还是卖**
264
-
265
- ## 标的检索规则(必须遵循)
266
-
267
- 当不能直接确定 `product_code`/`product_type` 时,必须先在本地标的数据中检索,再和用户确认。
268
-
269
- 数据文件(CSV,字段为 `类型,代码,名称,完整代码,市场`):
270
- - `data/stock_a.csv`:A股个股列表(`productType=stock`)
271
- - `data/stock_hk.csv`:港股个股列表(`productType=hk_stock`)
272
- - `data/index_a.csv`:A股指数列表(`productType=index`)
273
- - `data/index_hk.csv`:港股指数列表(支持指数代码和中文名称查询)
274
-
275
- ### 场景1:用户只说股票简称/名称
276
- - 使用模糊搜索在上述 CSV 中查找名称。
277
- - 若命中多条,必须把候选项(代码 + 名称 + 市场)发给用户确认,不要自行猜测。
278
- - 用户确认后再创建策略。
279
-
280
- ### 场景2:不知道某个标的代码或所属市场
281
- - 使用 `rg`(或 `grep`)在四个 CSV 中搜索标的名称或代码。
282
- - 根据命中结果判断市场并映射 `productType`:
283
- - A股个股 -> `stock`
284
- - 港股个股 -> `hk_stock`
285
- - A股指数 -> `index`
286
- - 港股指数 -> `index`(`market=HK`)
287
- - 若搜索结果不唯一或冲突,先向用户确认后再继续。
288
-
289
- ### 推荐检索命令
290
-
291
- ```bash
292
- # 按名称模糊查找(推荐)
293
- rg -n "平安银行|腾讯|沪深300|BTC" data/stock_a.csv data/stock_hk.csv data/index_a.csv
294
- rg -n "恒生科技指数|恒生指数|HSTECH|HSI" data/index_hk.csv
295
-
296
- # 按代码查找
297
- rg -n "000001|00700|399001" data/stock_a.csv data/stock_hk.csv data/index_a.csv
298
- rg -n "HSTECH|HSI|VHSI" data/index_hk.csv
299
-
300
- # grep 兜底(无 rg 时)
301
- grep -nE "平安银行|腾讯|沪深300|000001|00700|恒生科技指数|HSTECH" data/stock_a.csv data/stock_hk.csv data/index_a.csv data/index_hk.csv
302
- ```
303
-
304
- ## 行情查询(queryTickerData)
305
-
306
- 当用户问“现在多少钱”“最新价格”“查一下某标的行情”等问题时,执行以下流程:
307
-
308
- 1. 先根据用户输入确定标的代码与市场:
309
- - 如果是简称/名称,先在 `data/*.csv` 里模糊搜索并向用户确认候选。
310
- - 如果是明确代码,按代码在 `data/*.csv` 查对应 `市场`。
311
- - 港股指数可直接用代码(如 `HSTECH`)或中文名称(如 `恒生科技指数`)查询;命中 `index_hk.csv` 时优先使用 `market=HK`。
312
-
313
- 2. 调用已安装插件/包暴露的查询接口(例如 `queryTickerData`):
314
-
315
- ```javascript
316
- await runtime.queryTickerData({
317
- stockCode: '00700', // 或 productCode
318
- market: 'HK', // SH/SZ/HK,crypto 可传 ''
319
- productType: 'hk_stock'
320
- })
321
- ```
322
-
323
- 3. 根据返回结果给用户反馈:
324
- - `code = "000000"`:返回行情数据(如最新价格、涨跌幅等)。
325
- - 非 `000000`:返回失败原因,并建议用户确认代码/市场后重试。
326
-
327
- ## 渠道参数填写
328
-
329
- `openclaw` 渠道必传,`email` / `call` / `sms` / `dingtalk` 可选。如用户没明确说明使用邮件(email)、电话/外呼(call)、短信(sms)、钉钉(dingtalk)通知提醒,则只需要传入`openclaw`渠道。
330
-
331
- 但一旦用户选择了某个通知渠道,其配置参数必须完整填写:
332
- - 选择 `email` 必须提供 `channel_configs.email.to_address/template_id/title/content`
333
- - 选择 `call` 必须提供 `channel_configs.call.phone/customer_name/condition`
334
- - 选择 `sms` 必须提供 `channel_configs.sms.receiver(或phone)/template_id/content`
335
- - 选择 `dingtalk` 必须提供 `channel_configs.dingtalk.cas_id/template_id/msg_type/content`
336
-
337
- ### email 参数(channel_configs.email)
338
- - `to_address`:收件人邮箱(必填,缺失不可创建/不可发送)
339
- - `template_id`:邮件模板 ID(必填,默认为4,不需要修改)
340
- - `title`: 收到邮件的标题(必填)
341
- - `content`: 消息内容(必填)
342
- 示例:
343
- ```javascript
344
- channel_configs: {
345
- email: {
346
- to_address: 'demo@example.com',
347
- template_id: 4,
348
- title: '监控提醒',
349
- content: '测试消息1'
350
- }
351
- }
352
- ```
353
- 用户收到的是一封title为"监控提醒",内容为"测试消息1"的一封邮件
354
-
355
- ### call 参数(channel_configs.call)
356
- - `phone`:手机号(必填,缺失不可创建/不可发送)
357
- - `customer_name`:客户名称(必填)
358
- - `condition`:外呼内容(必填)
359
-
360
- 示例:
361
- ```javascript
362
- channel_configs: {
363
- call: {
364
- phone: '13800138000',
365
- customer_name: 'Demo',
366
- condition: '比特币价格突破阈值'
367
- }
368
- }
369
- ```
370
- 用户收到的是一通打给手机号码为13800138000的电话,电话内容为'比特币价格突破阈值'
371
-
372
-
373
- ### sms 参数(channel_configs.sms)
374
- - `receiver`:手机号(必填,必须是纯数字;缺失不可创建/不可发送)
375
- - `template_id`:短信模板 ID(必填,默认 90010,不需要修改)
376
- - `content`:短信变量内容(必填)
377
-
378
- 示例:
379
- ```javascript
380
- channel_configs: {
381
- sms: {
382
- receiver: '13800138000',
383
- template_id: 90010,
384
- content: '测试消息1'
385
- }
386
- }
387
- ```
388
- 用户收到的是一封发送给手机号码为13800138000的短信,短信内容为'测试消息1'
389
-
390
-
391
- ### 钉钉 参数(channel_configs.dingtalk)
392
- - `cas_id`:钉钉用户ID(必填,缺失不可创建/不可发送)
393
- - `template_id`:钉钉模板 ID(必填,默认 3,不需要修改)
394
- - `msg_type`: 消息类型(必填):`text`/`markdown`
395
- - `content`:消息内容(必填)
396
-
397
- 示例:
398
- ```javascript
399
- channel_configs: {
400
- dingtalk: {
401
- cas_id: 'user.dingtalk',
402
- template_id: 3,
403
- msg_type: 'text',
404
- content: '测试消息1'
405
- }
406
- }
407
- ```
408
- 用户收到的是一条发送给钉钉号为user.dingtalk的单聊消息,消息内容为'测试消息1'
409
-
42
+ - `operator_type`(固定 `rule`)
43
+ - `operator_parameters.condition`
44
+ - `operator_parameters.variables`
410
45
 
411
- ## 支持的市场
46
+ 缺任一项先追问,不猜测阈值。
412
47
 
413
- | 市场 | productType | 示例 | 说明 |
414
- |------|-------------|------|------|
415
- | A股个股 | stock | 000001 | 每3秒行情 |
416
- | A股指数 | index | 000300 | 每3秒行情 |
417
- | 港股指数 | index | HSTECH / 恒生科技指数 | 查询时 `market=HK` |
418
- | 港股 | hk_stock | 00700 | 延迟15分钟 |
419
- | 加密货币 | crypto | BTCUSDT | 每10秒行情 |
48
+ ### 绝对禁止项
420
49
 
421
- 意图映射建议:
422
- - 用户提到“指数/沪深300/上证” -> `index`
423
- - 用户提到“港股” -> `hk_stock`
424
- - 用户提到“比特币/BTC” -> `crypto`
425
- - 其余股票默认先按 `stock` 处理并在必要时追问确认
50
+ - `operator_type` 不是 `rule`
51
+ - `condition` 放到顶层(必须在 `operator_parameters.condition`)
52
+ - `channel_configs.*` 传成 JSON 字符串(必须是对象)
53
+ - 用户未要求的渠道被默认附加
54
+ - 通过 `watch.list` 传 `user_id/use_id` 越权查询
426
55
 
427
- 详细产品代码见 [references/markets.md](references/markets.md)
56
+ ## 渐进式披露(按需读取)
428
57
 
429
- ## 使用示例
58
+ 仅在命中场景时读取对应文档,不要一次性加载全部 references。
430
59
 
431
- ### 比特币监控
432
- ```javascript
433
- // 条件: 价格 >= 73000 且涨幅 >= 1%(放在 operator_parameters 内)
434
- operator_type: 'rule'
435
- operator_parameters: {
436
- condition: 'price >= threshold and change_percent >= cp_threshold',
437
- variables: { threshold: 73000, cp_threshold: 0.01, product_name: 'Bitcoin' }
438
- }
439
- // 注意: crypto 不支持 turnover_rate
440
- ```
60
+ - `watch.create/list/pause/activate/remove` 细节与成功判定:`references/watch-contract.md`
61
+ - 标的检索、行情查询流程:`references/query-and-symbol.md`
62
+ - 渠道参数、OpenClaw 路由、联系人记忆、`notify.*` 模板:`references/channels.md`
63
+ - 示例 payload:`references/examples.md`
64
+ - 重试、错误码、离线补发:`references/troubleshooting.md`
65
+ - 市场与代码速查:`references/markets.md`
441
66
 
442
- ### A股监控
443
- ```javascript
444
- // 条件: 价格 >= 12.5 且换手率 >= 1%(放在 operator_parameters 内)
445
- operator_type: 'rule'
446
- operator_parameters: {
447
- condition: 'price >= threshold and turnover_rate >= tr_threshold',
448
- variables: { threshold: 12.5, tr_threshold: 0.01, product_name: '平安银行' }
449
- }
450
- ```
67
+ ## 决策表(强约束)
451
68
 
452
- ### 港股监控
453
- ```javascript
454
- // 条件: 价格 >= 420(放在 operator_parameters 内)
455
- operator_type: 'rule'
456
- operator_parameters: {
457
- condition: 'price >= threshold',
458
- variables: { threshold: 420, product_name: '腾讯控股' }
459
- }
460
- ```
69
+ 先判定用户主意图,再只读取最小文档集合:
461
70
 
462
- ## 触发后操作
71
+ | 用户主意图 | 必读文档(最小集合) |
72
+ |---|---|
73
+ | 查行情/当前价格/报价 | `references/query-and-symbol.md` |
74
+ | 创建盯盘策略(代码和市场已明确) | `references/watch-contract.md` |
75
+ | 创建盯盘策略(名称或市场不明确) | `references/watch-contract.md` + `references/query-and-symbol.md` |
76
+ | 创建盯盘 + 指定通知渠道 | `references/watch-contract.md` + `references/channels.md` |
77
+ | 管理策略(list/pause/activate/remove) | `references/watch-contract.md` |
78
+ | 立即发通知(notify.*) | `references/channels.md` |
79
+ | 失败排查/补发说明 | `references/troubleshooting.md` |
80
+ | 市场或代码速查 | `references/markets.md` |
463
81
 
464
- 当监控触发时:
465
- 1. 解析 `market_data` 获取价格、涨跌幅等信息
466
- 2. 发送提醒到用户当前对话的渠道(群聊/私聊)
467
- 3. `openclaw` 渠道必传,`email/call/sms/dingtalk` 可按需附加
468
- 4. 根据触发消息构建友好的提醒文案
82
+ 执行规则:
83
+ 1. 一次请求默认只读 1-2 个 references 文件。
84
+ 2. 仅当当前文档无法回答时,再追加读取下一个文档。
85
+ 3. 读取顺序遵循上表,不按“完整性”一次加载所有文档。
469
86
 
470
- 如果创建失败(`watch.create.result.success=false`):
471
- - 明确返回失败原因给用户
472
- - 引导用户补充或修正参数后再次创建
87
+ ## 小模型执行模板(7B)
473
88
 
474
- 如果直连通知失败(`notify.send.result.success=false`):
475
- - 优先读取并返回 `code/error/hint`,不要只说“通知失败”
476
- - 若 `code=MISSING_REQUIRED_FIELD`,直接告诉用户缺失字段并让其补齐
477
- - `code=UNSUPPORTED_MESSAGE_TYPE`,提示“bridge 版本不支持 notify.send,需要升级并重启”
478
- - 若 `code=UPSTREAM_UNAVAILABLE`,提示“notification 服务不可用或超时,请稍后重试”
89
+ 1. 先按固定字段骨架生成 payload,不自创字段名。
90
+ 2. 只替换值:`product_code/product_type/operator_parameters/channels/channel_configs`。
91
+ 3. 调用前逐条自检“绝对禁止项”。
92
+ 4. 若报 `400` 且提示“未注册的算子类型”,把 `operator_type` 纠正为 `rule` 后重试。
479
93
 
480
- ## 相关资源
94
+ ## 快速分流
481
95
 
482
- - 市场参考: [references/markets.md](references/markets.md)
96
+ - 用户说“帮我盯 BTC 跌 2% 提醒” -> 读取 `references/watch-contract.md` + `references/channels.md`
97
+ - 用户说“腾讯现在多少钱” -> 读取 `references/query-and-symbol.md`
98
+ - 用户说“发短信给我” -> 读取 `references/channels.md`
99
+ - 用户说“为什么没发出来” -> 读取 `references/troubleshooting.md`
@@ -0,0 +1,80 @@
1
+ # 渠道与通知参数
2
+
3
+ ## 渠道策略
4
+
5
+ - `openclaw` 渠道默认必带(用于回当前会话)。
6
+ - `email/call/sms/dingtalk` 仅在用户明确要求时添加。
7
+
8
+ ## OpenClaw 会话路由
9
+
10
+ 当 `channels` 包含 `openclaw` 时,`channel_configs.openclaw` 必须可定位当前会话。
11
+
12
+ 常用字段:
13
+ - `channel` 或 `source_channel`
14
+ - `account_id`
15
+ - `session_key`
16
+ - `conversation_id` 或 `chat_id`
17
+
18
+ 约束:
19
+ - 拿不到路由信息时,不得传空对象 `openclaw: {}` 假装已配置。
20
+ - 宿主传入 `context` 时,插件运行时只负责合并 openclaw 路由字段。
21
+
22
+ ## 联系人记忆(Agent/OpenClaw 侧 CSV)
23
+
24
+ 真源:`~/.openclaw/workspace/memory/watch-notify-contacts.csv`
25
+
26
+ 建议表头:
27
+
28
+ ```text
29
+ channel,sender_id,sender_name,phone,email,dingtalk_cas_id,customer_name,updated_at,notes
30
+ ```
31
+
32
+ 取值优先级:
33
+ 1. 本轮用户明确提供
34
+ 2. CSV 历史默认值
35
+ 3. 仍缺必填字段 -> 追问
36
+
37
+ 查询示例:
38
+
39
+ ```bash
40
+ rg -n '^dingtalk,jinguo\.xie,' ~/.openclaw/workspace/memory/watch-notify-contacts.csv
41
+ ```
42
+
43
+ ## `watch.create` 渠道必填
44
+
45
+ - 选 `email` -> `channel_configs.email.to_address/template_id/title/content`
46
+ - 选 `call` -> `channel_configs.call.phone/customer_name/condition`
47
+ - 选 `sms` -> `channel_configs.sms.receiver(或phone)/template_id/content`
48
+ - 选 `dingtalk` -> `channel_configs.dingtalk.cas_id/template_id/msg_type/content`
49
+
50
+ ## `notify.*` 参数
51
+
52
+ - `notify.sms`: `receiver(或phone)`、`template_id`、`content`
53
+ - `notify.call`: `phone`、`customer_name`、`condition`
54
+ - `notify.email`: `to_address`、`template_id`、`title`、`content`
55
+ - `notify.dingtalk`: `cas_id`、`template_id`、`msg_type`、`content`
56
+
57
+ 固定模板:
58
+
59
+ ```javascript
60
+ // notify.sms
61
+ { receiver: '13800138000', template_id: 90010, content: '测试消息1' }
62
+
63
+ // notify.call
64
+ { phone: '13800138000', customer_name: 'Demo', condition: '比特币跌幅超过2%' }
65
+
66
+ // notify.email
67
+ { to_address: 'demo@example.com', template_id: 4, title: '监控提醒', content: '测试消息1' }
68
+
69
+ // notify.dingtalk
70
+ { cas_id: 'user.dingtalk', template_id: 3, msg_type: 'text', content: '测试消息1' }
71
+ ```
72
+
73
+ 禁止项:
74
+ - 手机号含空格、`+86-`、中划线等非纯数字
75
+ - `template_id` 传字符串
76
+ - `msg_type` 非 `text/markdown`
77
+ - 手动传 `request_id`
78
+
79
+ 成功判定:
80
+ - `success = true`
@@ -0,0 +1,43 @@
1
+ # 典型示例
2
+
3
+ ## 比特币监控
4
+
5
+ ```javascript
6
+ operator_type: 'rule'
7
+ operator_parameters: {
8
+ condition: 'price >= threshold and change_percent >= cp_threshold',
9
+ variables: { threshold: 73000, cp_threshold: 0.01, product_name: 'Bitcoin' }
10
+ }
11
+ ```
12
+
13
+ 说明:`crypto` 不支持 `turnover_rate`。
14
+
15
+ ## A 股监控
16
+
17
+ ```javascript
18
+ operator_type: 'rule'
19
+ operator_parameters: {
20
+ condition: 'price >= threshold and turnover_rate >= tr_threshold',
21
+ variables: { threshold: 12.5, tr_threshold: 0.01, product_name: '平安银行' }
22
+ }
23
+ ```
24
+
25
+ ## 港股监控
26
+
27
+ ```javascript
28
+ operator_type: 'rule'
29
+ operator_parameters: {
30
+ condition: 'price >= threshold',
31
+ variables: { threshold: 420, product_name: '腾讯控股' }
32
+ }
33
+ ```
34
+
35
+ ## 查询行情
36
+
37
+ ```javascript
38
+ await runtime.queryTickerData({
39
+ stockCode: '00700',
40
+ market: 'HK',
41
+ productType: 'hk_stock'
42
+ })
43
+ ```
@@ -0,0 +1,69 @@
1
+ # 标的检索与行情查询
2
+
3
+ ## 标的检索规则(必须遵循)
4
+
5
+ 当不能直接确定 `product_code` / `product_type` 时,先查本地 CSV,再让用户确认。
6
+
7
+ 数据文件(字段:`类型,代码,名称,完整代码,市场`):
8
+ - `data/stock_a.csv` -> A 股个股(`productType=stock`)
9
+ - `data/stock_hk.csv` -> 港股个股(`productType=hk_stock`)
10
+ - `data/index_a.csv` -> A 股指数(`productType=index`)
11
+ - `data/index_hk.csv` -> 港股指数(`productType=index`,查询常配 `market=HK`)
12
+
13
+ ## 场景 1:用户只说名称
14
+
15
+ - 在 CSV 里按名称模糊搜索。
16
+ - 命中多条时,必须给出候选(代码 + 名称 + 市场)让用户确认。
17
+ - 不可自行猜测后直接创建策略。
18
+
19
+ ## 场景 2:用户不知道代码或市场
20
+
21
+ - 用 `rg` 在 4 个 CSV 搜索名称或代码。
22
+ - 映射规则:
23
+ - A 股个股 -> `stock`
24
+ - 港股个股 -> `hk_stock`
25
+ - A 股指数 -> `index`
26
+ - 港股指数 -> `index`(`market=HK`)
27
+ - 结果不唯一时先追问。
28
+
29
+ ## 推荐检索命令
30
+
31
+ ```bash
32
+ # 按名称模糊查找
33
+ rg -n "平安银行|腾讯|沪深300|BTC" data/stock_a.csv data/stock_hk.csv data/index_a.csv
34
+ rg -n "恒生科技指数|恒生指数|HSTECH|HSI" data/index_hk.csv
35
+
36
+ # 按代码查找
37
+ rg -n "000001|00700|399001" data/stock_a.csv data/stock_hk.csv data/index_a.csv
38
+ rg -n "HSTECH|HSI|VHSI" data/index_hk.csv
39
+
40
+ # 无 rg 时兜底
41
+ grep -nE "平安银行|腾讯|沪深300|000001|00700|恒生科技指数|HSTECH" \
42
+ data/stock_a.csv data/stock_hk.csv data/index_a.csv data/index_hk.csv
43
+ ```
44
+
45
+ ## 行情查询流程(`watch.query_ticker`)
46
+
47
+ 说明:
48
+ - 对外统一动作名是 `watch.query_ticker`。
49
+ - 文档中的 `runtime.queryTickerData(...)` 仅用于说明宿主运行时内部调用形态。
50
+
51
+ 1. 先确定标的代码、市场、`productType`。
52
+ 2. 调用查询动作。
53
+ 3. 成功后反馈价格/涨跌幅,失败则返回错误并让用户确认代码或市场。
54
+
55
+ 示例:
56
+
57
+ ```javascript
58
+ await runtime.queryTickerData({
59
+ stockCode: '00700',
60
+ market: 'HK',
61
+ productType: 'hk_stock'
62
+ })
63
+ ```
64
+
65
+ 成功判定:
66
+ - `code = "000000"` 或 `success = true`
67
+
68
+ 失败处理:
69
+ - 直接返回失败原因,不静默重试
@@ -0,0 +1,40 @@
1
+ # 重试与排障
2
+
3
+ ## 统一重试原则
4
+
5
+ - 超时或网络波动时可重试。
6
+ - 重试必须使用同一组 payload(字段和值不变)。
7
+ - `request_id` 由插件运行时自动生成并在同 payload 上复用。
8
+
9
+ ## 创建策略失败(`watch.create`)
10
+
11
+ 处理规则:
12
+ - 明确返回失败原因,不静默重试。
13
+ - 若报“未注册的算子类型”,将 `operator_type` 修正为 `rule` 后重试。
14
+
15
+ ## 通知失败(`notify.*`)
16
+
17
+ 处理规则:
18
+ - 优先返回 `code/error/hint`。
19
+ - `MISSING_REQUIRED_FIELD`:仅补缺失字段后重试。
20
+ - `UNSUPPORTED_MESSAGE_TYPE`:提示 bridge 版本不支持,需升级并重启。
21
+ - `UPSTREAM_UNAVAILABLE`:提示上游不可用或超时,稍后重试。
22
+
23
+ ## 离线补发识别(`watch.triggered`)
24
+
25
+ 满足任一条件视为离线补发:
26
+ - `delivery_mode = "offline_replay"`
27
+ - `replayed = true`
28
+
29
+ 时间语义:
30
+ - `trigger_time`:原始触发时间
31
+ - `replayed_at`:补发时间
32
+
33
+ 用户文案需明确“离线期间触发,当前为补发”。
34
+
35
+ ## 触发后动作
36
+
37
+ 1. 解析 `market_data` 提取价格、涨跌幅。
38
+ 2. 通过 `openclaw` 回当前群/私聊。
39
+ 3. 附加渠道按用户要求发送。
40
+ 4. 生成简洁提醒文案。
@@ -0,0 +1,132 @@
1
+ # Watch 动作契约
2
+
3
+ ## `watch.query_ticker`
4
+
5
+ 参数:
6
+ - `stockCode`(或 `productCode`)
7
+ - `productType`
8
+ - `market`(`crypto` 可传空字符串)
9
+
10
+ 成功判定:
11
+ - `code = "000000"` 或 `success = true`
12
+
13
+ 失败处理:
14
+ - 返回失败原因
15
+ - 引导用户确认代码/市场后重试
16
+
17
+ ## `watch.create`
18
+
19
+ 最小参数:
20
+ - `product_code`
21
+ - `product_type`
22
+ - `operator_type`(固定 `rule`)
23
+ - `operator_parameters`(包含 `condition`、`variables`)
24
+
25
+ 建议参数:
26
+ - `channels`(默认至少 `openclaw`)
27
+ - `channel_configs.*`
28
+
29
+ 固定结构(字段名不可改):
30
+
31
+ ```javascript
32
+ {
33
+ product_code: 'BTCUSDT',
34
+ product_type: 'crypto',
35
+ operator_type: 'rule',
36
+ operator_parameters: {
37
+ condition: 'change_percent <= cp_threshold',
38
+ variables: {
39
+ cp_threshold: -0.02,
40
+ product_name: '比特币'
41
+ }
42
+ },
43
+ channels: ['openclaw', 'dingtalk', 'sms'],
44
+ channel_configs: {
45
+ openclaw: {
46
+ channel: 'dingtalk',
47
+ account_id: 'default',
48
+ session_key: 'agent:main:dingtalk:group:<conversation_id>',
49
+ conversation_id: '<conversation_id>'
50
+ },
51
+ dingtalk: {
52
+ cas_id: 'jinguo.xie',
53
+ template_id: 3,
54
+ msg_type: 'text',
55
+ content: '比特币跌幅超2%!当前价格 ${price},跌幅 ${change_percent}%。建议卖出!'
56
+ },
57
+ sms: {
58
+ receiver: '13800138000',
59
+ template_id: 90010,
60
+ content: '比特币跌幅超2%!当前价格 ${price},建议卖出!'
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ 禁止项:
67
+ - `operator_type` 非 `rule`
68
+ - 顶层 `condition`
69
+ - `channel_configs` 渠道配置是字符串
70
+ - 用户未要求渠道却默认附加
71
+
72
+ 成功判定:
73
+ - `success = true`
74
+
75
+ 失败处理:
76
+ - 明确返回失败原因,不静默重试
77
+ - 超时/网络波动重试时,使用同一 payload(字段和值不变)
78
+ - `request_id` 由插件运行时自动生成并复用,大模型不手动传
79
+
80
+ ## `watch.pause` / `watch.activate` / `watch.remove`
81
+
82
+ 参数:
83
+ - `strategyId`(或 `strategy_id`)
84
+
85
+ 成功判定:
86
+ - `success = true`
87
+
88
+ 失败处理:
89
+ - 返回失败原因并提示用户确认策略 ID
90
+
91
+ ## `watch.list`
92
+
93
+ 可选参数:
94
+ - `status`: `active/paused/completed/failed/expired`
95
+ - `product_code`(或 `productCode`)
96
+
97
+ 成功判定:
98
+ - `success = true`
99
+ - `data.total` 为命中数量
100
+ - `data.strategies` 为策略列表
101
+
102
+ 失败处理:
103
+ - 返回失败原因,不静默重试
104
+ - 空结果明确告知“当前条件下没有策略”
105
+
106
+ 安全约束:
107
+ - 仅可查询当前连接用户自己的策略
108
+ - 不通过 `user_id/use_id` 越权查询
109
+
110
+ ## 调用前最终检查
111
+
112
+ 1. `watch.create` 使用 snake_case:`product_code/product_type/operator_type/operator_parameters/channel_configs`
113
+ 2. `operator_type` 固定 `rule`
114
+ 3. `operator_parameters.condition` 与 `variables` 同时存在
115
+ 4. `channels` 与 `channel_configs` 一一对应
116
+ 5. 渠道配置是对象,不是 JSON 字符串
117
+ 6. 默认不手动传 `request_id`
118
+
119
+ ## 买卖意图与条件方向
120
+
121
+ | 用户意图 | 条件方向 |
122
+ |---------|---------|
123
+ | 买入(逢低) | `price <= threshold` |
124
+ | 卖出(止盈/止损) | `price >= threshold` |
125
+
126
+ 判断规则:
127
+ 1. 用户明确说“涨到/跌到”时,按方向直接生成条件。
128
+ 2. 用户只说“到了 XX 提醒我”时,必须追问“买还是卖”。
129
+ 3. 常见映射:
130
+ - `涨到/涨过/突破/冲到` -> `price >= threshold`
131
+ - `跌到/跌破/回调到/回到` -> `price <= threshold`
132
+ - `到了/到达/价格到` -> 方向不明确,需追问
@@ -1,4 +1,5 @@
1
1
  import { OpenClawBridgeClient } from './OpenClawBridgeClient.js';
2
+ import { extractOpenclawRoutingFromRecord } from './openclawRouting.js';
2
3
 
3
4
  /**
4
5
  * 全局单例 Adapter 实例
@@ -83,7 +84,6 @@ export class OpenClawPluginAdapter {
83
84
  const channelConfigs = { ...(demand.channelConfigs || {}) };
84
85
 
85
86
  if (demand.openclawConfig) {
86
- channelConfigs.openclaw = demand.openclawConfig;
87
87
  if (!channels.includes('openclaw')) channels.push('openclaw');
88
88
  }
89
89
  if (demand.emailConfig) {
@@ -103,7 +103,20 @@ export class OpenClawPluginAdapter {
103
103
  if (!channels.includes('dingtalk')) channels.push('dingtalk');
104
104
  }
105
105
  if (!channels.includes('openclaw')) channels.unshift('openclaw');
106
- if (!channelConfigs.openclaw) channelConfigs.openclaw = {};
106
+
107
+ const existingOpenclaw =
108
+ channelConfigs.openclaw && typeof channelConfigs.openclaw === 'object'
109
+ ? { ...channelConfigs.openclaw }
110
+ : {};
111
+ const explicitOpenclaw =
112
+ demand.openclawConfig && typeof demand.openclawConfig === 'object'
113
+ ? { ...demand.openclawConfig }
114
+ : {};
115
+ channelConfigs.openclaw = {
116
+ ...extractOpenclawRoutingFromRecord(demand),
117
+ ...existingOpenclaw,
118
+ ...explicitOpenclaw
119
+ };
107
120
 
108
121
  const payload = {
109
122
  product_code: demand.productCode,
@@ -0,0 +1,174 @@
1
+ /**
2
+ * openclaw-bridge 约定的会话路由字段(snake_case),用于落库与触发回推。
3
+ * @see ticker-monitor services/openclaw_bridge/main.py _extract_openclaw_routing
4
+ */
5
+
6
+ export function pickFirstString(...values) {
7
+ for (const value of values) {
8
+ if (typeof value === 'string' && value.trim()) {
9
+ return value.trim();
10
+ }
11
+ }
12
+ return undefined;
13
+ }
14
+
15
+ /**
16
+ * 用于 senderId、路由 ID 等:非空字符串 trim,有限数字 / bigint 转字符串。
17
+ * (纯 pickFirstString 会忽略数字类型,导致记忆主键丢失。)
18
+ */
19
+ export function pickFirstTrimmedScalar(...values) {
20
+ for (const value of values) {
21
+ if (value == null) continue;
22
+ if (typeof value === 'string') {
23
+ const t = value.trim();
24
+ if (t) return t;
25
+ continue;
26
+ }
27
+ if (typeof value === 'number' && Number.isFinite(value)) {
28
+ return String(value);
29
+ }
30
+ if (typeof value === 'bigint') {
31
+ return String(value);
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * 用于发送者主键:忽略数字 / bigint 0(常见占位),避免生成 `unknown:0` 等无效记忆键。
39
+ */
40
+ export function pickFirstSenderIdentifier(...values) {
41
+ for (const value of values) {
42
+ if (value == null) continue;
43
+ if (typeof value === 'string') {
44
+ const t = value.trim();
45
+ if (t) return t;
46
+ continue;
47
+ }
48
+ if (typeof value === 'number' && Number.isFinite(value)) {
49
+ if (value === 0) continue;
50
+ return String(value);
51
+ }
52
+ if (typeof value === 'bigint') {
53
+ if (value === 0n) continue;
54
+ return String(value);
55
+ }
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * 从任意平面对象提取路由(支持 camelCase / snake_case),返回 bridge 使用的 snake_case。
62
+ * @param {Record<string, unknown>} source
63
+ * @returns {Record<string, string>}
64
+ */
65
+ export function extractOpenclawRoutingFromRecord(source = {}) {
66
+ if (!source || typeof source !== 'object') {
67
+ return {};
68
+ }
69
+ const channel = pickFirstString(
70
+ source.channel,
71
+ source.source_channel,
72
+ source.sourceChannel
73
+ );
74
+ const account_id = pickFirstString(source.account_id, source.accountId);
75
+ const session_key = pickFirstString(source.session_key, source.sessionKey);
76
+ const conversation_id = pickFirstString(
77
+ source.conversation_id,
78
+ source.conversationId,
79
+ source.chat_id,
80
+ source.chatId
81
+ );
82
+ const out = {};
83
+ if (channel) out.channel = channel;
84
+ if (account_id) out.account_id = account_id;
85
+ if (session_key) out.session_key = session_key;
86
+ if (conversation_id) out.conversation_id = conversation_id;
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * 合并 params 与宿主 context / event.metadata 上的路由字段(与插件 index 行为一致)。
92
+ * @param {{ params?: Record<string, unknown>, context?: Record<string, unknown> }} args
93
+ * @returns {Record<string, string>}
94
+ */
95
+ export function mergeContextMetadata(context = {}) {
96
+ const flat =
97
+ context.metadata && typeof context.metadata === 'object' && !Array.isArray(context.metadata)
98
+ ? context.metadata
99
+ : {};
100
+ const eventMeta =
101
+ context.event?.metadata &&
102
+ typeof context.event.metadata === 'object' &&
103
+ !Array.isArray(context.event.metadata)
104
+ ? context.event.metadata
105
+ : {};
106
+ return { ...flat, ...eventMeta };
107
+ }
108
+
109
+ export function unwrapSenderContextObject(context = {}) {
110
+ const sc = context?.senderContext;
111
+ if (sc && typeof sc === 'object' && !Array.isArray(sc)) {
112
+ return sc;
113
+ }
114
+ return {};
115
+ }
116
+
117
+ export function deriveOpenclawRouting({ params = {}, context = {} } = {}) {
118
+ const metadata = mergeContextMetadata(context);
119
+ const sc = unwrapSenderContextObject(context);
120
+ const routing = extractOpenclawRoutingFromRecord(params || {});
121
+
122
+ if (!routing.channel) {
123
+ const channel = pickFirstString(
124
+ sc.channel,
125
+ sc.sourceChannel,
126
+ sc.source_channel,
127
+ params?.source_channel,
128
+ metadata?.channel,
129
+ metadata?.channelId,
130
+ context?.channel,
131
+ context?.channelId
132
+ );
133
+ if (channel) routing.channel = channel;
134
+ }
135
+ if (!routing.account_id) {
136
+ const account_id = pickFirstString(
137
+ params?.account_id,
138
+ sc.accountId,
139
+ sc.account_id,
140
+ context?.accountId,
141
+ metadata?.accountId
142
+ );
143
+ if (account_id) routing.account_id = account_id;
144
+ }
145
+ if (!routing.session_key) {
146
+ const session_key = pickFirstString(
147
+ params?.session_key,
148
+ params?.sessionKey,
149
+ sc.sessionKey,
150
+ sc.session_key,
151
+ context?.sessionKey,
152
+ metadata?.sessionKey
153
+ );
154
+ if (session_key) routing.session_key = session_key;
155
+ }
156
+ if (!routing.conversation_id) {
157
+ const conversation_id = pickFirstString(
158
+ params?.conversation_id,
159
+ params?.conversationId,
160
+ params?.chat_id,
161
+ params?.chatId,
162
+ sc.conversationId,
163
+ sc.conversation_id,
164
+ sc.chatId,
165
+ sc.chat_id,
166
+ context?.conversationId,
167
+ metadata?.conversationId,
168
+ metadata?.chatId,
169
+ metadata?.groupId
170
+ );
171
+ if (conversation_id) routing.conversation_id = conversation_id;
172
+ }
173
+ return routing;
174
+ }
@@ -1,4 +1,5 @@
1
1
  import { resolveRuntimeConfig } from '../config/runtime-config.js';
2
+ import { extractOpenclawRoutingFromRecord, deriveOpenclawRouting } from '../openclawRouting.js';
2
3
  import { BridgeRuntime } from '../runtime/BridgeRuntime.js';
3
4
  import { PluginDispatcher } from '../runtime/dispatchers/PluginDispatcher.js';
4
5
  import { ProcessLock } from '../runtime/lock/ProcessLock.js';
@@ -56,52 +57,6 @@ async function getReadyRuntime(startupPromise) {
56
57
  return activeRuntime;
57
58
  }
58
59
 
59
- function pickFirstString(...values) {
60
- for (const value of values) {
61
- if (typeof value === 'string' && value.trim()) {
62
- return value.trim();
63
- }
64
- }
65
- return undefined;
66
- }
67
-
68
- function deriveOpenclawRouting({ params = {}, context = {} } = {}) {
69
- const metadata = context?.event?.metadata || {};
70
-
71
- const channel = pickFirstString(
72
- params?.channel,
73
- params?.source_channel,
74
- metadata?.channel,
75
- metadata?.channelId,
76
- context?.channel,
77
- context?.channelId
78
- );
79
- const accountId = pickFirstString(params?.account_id, context?.accountId, metadata?.accountId);
80
- const sessionKey = pickFirstString(
81
- params?.session_key,
82
- params?.sessionKey,
83
- context?.sessionKey,
84
- metadata?.sessionKey
85
- );
86
- const conversationId = pickFirstString(
87
- params?.conversation_id,
88
- params?.conversationId,
89
- params?.chat_id,
90
- params?.chatId,
91
- context?.conversationId,
92
- metadata?.conversationId,
93
- metadata?.chatId,
94
- metadata?.groupId
95
- );
96
-
97
- const routing = {};
98
- if (channel) routing.channel = channel;
99
- if (accountId) routing.account_id = accountId;
100
- if (sessionKey) routing.session_key = sessionKey;
101
- if (conversationId) routing.conversation_id = conversationId;
102
- return routing;
103
- }
104
-
105
60
  function mapDemandToCreatePayload(demand = {}) {
106
61
  const channels = Array.isArray(demand.channels)
107
62
  ? demand.channels
@@ -111,7 +66,6 @@ function mapDemandToCreatePayload(demand = {}) {
111
66
  const channelConfigs = { ...(demand.channelConfigs || {}) };
112
67
 
113
68
  if (demand.openclawConfig) {
114
- channelConfigs.openclaw = demand.openclawConfig;
115
69
  if (!channels.includes('openclaw')) channels.push('openclaw');
116
70
  }
117
71
  if (demand.emailConfig) {
@@ -131,7 +85,20 @@ function mapDemandToCreatePayload(demand = {}) {
131
85
  if (!channels.includes('dingtalk')) channels.push('dingtalk');
132
86
  }
133
87
  if (!channels.includes('openclaw')) channels.unshift('openclaw');
134
- if (!channelConfigs.openclaw) channelConfigs.openclaw = {};
88
+
89
+ const existingOpenclaw =
90
+ channelConfigs.openclaw && typeof channelConfigs.openclaw === 'object'
91
+ ? { ...channelConfigs.openclaw }
92
+ : {};
93
+ const explicitOpenclaw =
94
+ demand.openclawConfig && typeof demand.openclawConfig === 'object'
95
+ ? { ...demand.openclawConfig }
96
+ : {};
97
+ channelConfigs.openclaw = {
98
+ ...extractOpenclawRoutingFromRecord(demand),
99
+ ...existingOpenclaw,
100
+ ...explicitOpenclaw
101
+ };
135
102
 
136
103
  return {
137
104
  product_code: demand.productCode || demand.product_code,
@@ -197,11 +164,19 @@ function buildControlApi(startupPromise) {
197
164
  },
198
165
  async sendNotification(input = {}) {
199
166
  const runtime = await getReadyRuntime(startupPromise);
200
- const channel = input.channel;
167
+ const ch = String(input.channel ?? '')
168
+ .trim()
169
+ .toLowerCase();
170
+ const allowed = new Set(['sms', 'email', 'call', 'dingtalk']);
171
+ if (!ch || !allowed.has(ch)) {
172
+ throw new Error(
173
+ 'notify.send requires input.channel to be one of: sms, email, call, dingtalk'
174
+ );
175
+ }
201
176
  const payload = { ...(input.payload || {}) };
202
177
  return runtime.request('notify.send', {
203
178
  ...payload,
204
- channel
179
+ channel: ch
205
180
  });
206
181
  },
207
182
  async sendSms(payload = {}) {
@@ -219,7 +194,8 @@ function buildControlApi(startupPromise) {
219
194
  async submitWatchDemand(demand = {}, context = {}) {
220
195
  const runtime = await getReadyRuntime(startupPromise);
221
196
  const payload = mapDemandToCreatePayload(demand);
222
- return runtime.request('watch.create', mergeOpenclawChannelConfig(payload, context));
197
+ const normalized = mergeOpenclawChannelConfig(payload, context);
198
+ return runtime.request('watch.create', normalized);
223
199
  },
224
200
  async pauseWatch(strategyId) {
225
201
  const runtime = await getReadyRuntime(startupPromise);