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 +1 -1
- package/skills/glance-watch/SKILL.md +57 -440
- package/skills/glance-watch/references/channels.md +80 -0
- package/skills/glance-watch/references/examples.md +43 -0
- package/skills/glance-watch/references/query-and-symbol.md +69 -0
- package/skills/glance-watch/references/troubleshooting.md +40 -0
- package/skills/glance-watch/references/watch-contract.md +132 -0
- package/src/OpenClawPluginAdapter.js +15 -2
- package/src/openclawRouting.js +174 -0
- package/src/plugin/index.js +27 -51
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
45
|
-
2.
|
|
46
|
-
3.
|
|
47
|
-
4.
|
|
48
|
-
5.
|
|
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
|
-
|
|
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
|
-
- `
|
|
227
|
-
- `operator_parameters.
|
|
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
|
-
|
|
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
|
-
-
|
|
423
|
-
-
|
|
424
|
-
-
|
|
425
|
-
-
|
|
50
|
+
- `operator_type` 不是 `rule`
|
|
51
|
+
- 把 `condition` 放到顶层(必须在 `operator_parameters.condition`)
|
|
52
|
+
- 把 `channel_configs.*` 传成 JSON 字符串(必须是对象)
|
|
53
|
+
- 用户未要求的渠道被默认附加
|
|
54
|
+
- 通过 `watch.list` 传 `user_id/use_id` 越权查询
|
|
426
55
|
|
|
427
|
-
|
|
56
|
+
## 渐进式披露(按需读取)
|
|
428
57
|
|
|
429
|
-
|
|
58
|
+
仅在命中场景时读取对应文档,不要一次性加载全部 references。
|
|
430
59
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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.
|
|
466
|
-
2.
|
|
467
|
-
3.
|
|
468
|
-
4. 根据触发消息构建友好的提醒文案
|
|
82
|
+
执行规则:
|
|
83
|
+
1. 一次请求默认只读 1-2 个 references 文件。
|
|
84
|
+
2. 仅当当前文档无法回答时,再追加读取下一个文档。
|
|
85
|
+
3. 读取顺序遵循上表,不按“完整性”一次加载所有文档。
|
|
469
86
|
|
|
470
|
-
|
|
471
|
-
- 明确返回失败原因给用户
|
|
472
|
-
- 引导用户补充或修正参数后再次创建
|
|
87
|
+
## 小模型执行模板(7B)
|
|
473
88
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/plugin/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|