openclaw-glance-plugin 0.1.15 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/openclaw.plugin.json +4 -0
- package/package.json +1 -1
- package/skills/glance-watch/SKILL.md +73 -7
- package/src/OpenClawBridgeClient.js +4 -0
- package/src/OpenClawPluginAdapter.js +19 -2
- package/src/config/runtime-config.js +15 -1
- package/src/openclawRouting.js +174 -0
- package/src/plugin/index.js +121 -27
- package/src/plugin/watch-notify-contacts.js +478 -0
- package/src/runtime/dispatchers/PluginDispatcher.js +53 -3
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- 与 `openclaw-bridge` 建立 WebSocket 长连接
|
|
24
24
|
- 支持请求:`watch.create` / `watch.activate` / `watch.pause` / `watch.delete` / `ticker.query` / `notify.send` / `ping`
|
|
25
25
|
- 支持渠道:`openclaw` / `email` / `call` / `sms` / `dingtalk`
|
|
26
|
+
- 建议在 `channel_configs.openclaw` 中携带路由字段(如 `channel`、`session_key`、`account_id`、`conversation_id`),便于触发后回推到正确会话
|
|
26
27
|
- 订阅推送:`watch.triggered`
|
|
27
28
|
- 主动发起通知结果推送:`notify.sent`
|
|
28
29
|
- 自动重连 + 心跳
|
|
@@ -127,6 +128,7 @@ await adapter.submitWatchDemand({
|
|
|
127
128
|
|
|
128
129
|
- `watch_query_ticker`
|
|
129
130
|
- `watch_create`
|
|
131
|
+
- `watch_list`
|
|
130
132
|
- `watch_pause`
|
|
131
133
|
- `watch_activate`
|
|
132
134
|
- `watch_remove`
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -30,6 +30,7 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
30
30
|
|
|
31
31
|
- `watch.query_ticker`
|
|
32
32
|
- `watch.create`
|
|
33
|
+
- `watch.list`
|
|
33
34
|
- `watch.pause`
|
|
34
35
|
- `watch.activate`
|
|
35
36
|
- `watch.remove`
|
|
@@ -43,7 +44,8 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
43
44
|
1. 用户是“查行情”意图:先调用 `watch.query_ticker`
|
|
44
45
|
2. 用户是“盯盘创建”意图:先补齐参数后调用 `watch.create`
|
|
45
46
|
3. 用户是“暂停/恢复/删除”意图:分别调用 `watch.pause` / `watch.activate` / `watch.remove`
|
|
46
|
-
4.
|
|
47
|
+
4. 用户是“查看策略/查看我的策略/看 active 或 paused 或 completed 策略”意图:调用 `watch.list`
|
|
48
|
+
5. 用户是“立即发短信/打电话/发邮件/发钉钉”意图:调用 `notify.sms` / `notify.call` / `notify.email` / `notify.dingtalk`
|
|
47
49
|
|
|
48
50
|
禁止跳步:创建盯盘前若缺关键字段必须先追问。
|
|
49
51
|
|
|
@@ -75,6 +77,19 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
75
77
|
- `channels`(默认至少包含 `openclaw`)
|
|
76
78
|
- 对应渠道配置(`channel_configs.email/call/sms/dingtalk`)
|
|
77
79
|
|
|
80
|
+
**OpenClaw 会话路由(含 `openclaw` 渠道时必须带上)**
|
|
81
|
+
触发后要把提醒发回**当前群聊/私聊**,须把路由写入 `channel_configs.openclaw`(或由宿主 `context` 注入,由插件运行时合并)。字段与 openclaw-bridge 一致(snake_case;部分宿主可用 camelCase,由插件归一):
|
|
82
|
+
|
|
83
|
+
| 字段 | 含义 |
|
|
84
|
+
|------|------|
|
|
85
|
+
| `channel` / `source_channel` | 来源渠道(钉钉/飞书等与宿主约定) |
|
|
86
|
+
| `account_id` | 多账号场景下的账号标识 |
|
|
87
|
+
| `session_key` | 推荐:可区分群/私聊、多会话的会话键 |
|
|
88
|
+
| `conversation_id` / `chat_id` | 宿主侧发送目标会话 ID |
|
|
89
|
+
|
|
90
|
+
可从**当前 OpenClaw 上下文**映射到上述字段;**禁止**在拿不到会话信息时留空 `openclaw: {}` 仍假装已配置。宿主通过工具调用传入的 `context`(如 `channelId`、`sessionKey`、`conversationId`)时,插件运行时会合并进 `channel_configs.openclaw`。
|
|
91
|
+
触发后解析 `watch.triggered` 的 **`payload.channel_configs.openclaw`**(及并列路由字段)再回复到对应会话。
|
|
92
|
+
|
|
78
93
|
固定模板(必须按此结构构造,字段名不要改):
|
|
79
94
|
|
|
80
95
|
```javascript
|
|
@@ -92,7 +107,12 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
92
107
|
channels: ['openclaw', 'dingtalk', 'sms'],
|
|
93
108
|
// 注意:必须是对象,不要传 JSON 字符串
|
|
94
109
|
channel_configs: {
|
|
95
|
-
openclaw: {
|
|
110
|
+
openclaw: {
|
|
111
|
+
channel: 'dingtalk',
|
|
112
|
+
account_id: 'default',
|
|
113
|
+
session_key: 'agent:main:dingtalk:group:<conversation_id>',
|
|
114
|
+
conversation_id: '<conversation_id>'
|
|
115
|
+
},
|
|
96
116
|
dingtalk: {
|
|
97
117
|
cas_id: 'jinguo.xie',
|
|
98
118
|
template_id: 3,
|
|
@@ -146,6 +166,24 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
146
166
|
失败处理:
|
|
147
167
|
- 返回失败原因并提示用户确认策略 ID
|
|
148
168
|
|
|
169
|
+
#### `watch.list`
|
|
170
|
+
|
|
171
|
+
参数(可选):
|
|
172
|
+
- `status`:策略状态过滤。可传 `active` / `paused` / `completed` / `failed` / `expired`;不传表示查询该用户全部策略
|
|
173
|
+
- `product_code`(或 `productCode`):按标的代码过滤
|
|
174
|
+
|
|
175
|
+
成功判定:
|
|
176
|
+
- 返回 `success = true`
|
|
177
|
+
- `data.total` 为命中策略数,`data.strategies` 为策略列表
|
|
178
|
+
|
|
179
|
+
失败处理:
|
|
180
|
+
- 返回失败原因,不要静默重试
|
|
181
|
+
- 若筛选条件为空结果,明确告知“当前条件下没有策略”
|
|
182
|
+
|
|
183
|
+
安全约束(必须):
|
|
184
|
+
- `watch.list` 只能查询当前连接用户自己的策略
|
|
185
|
+
- 不要尝试通过参数传 `user_id` / `use_id` 越权查询
|
|
186
|
+
|
|
149
187
|
#### `notify.sms` / `notify.call` / `notify.email` / `notify.dingtalk`
|
|
150
188
|
|
|
151
189
|
参数:
|
|
@@ -188,6 +226,11 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
188
226
|
回执说明:
|
|
189
227
|
- 直连通知发送完成后,客户端会收到 `notify.sent` 事件(`overall_status/success_count/failed_count/deliveries`)
|
|
190
228
|
|
|
229
|
+
离线补发识别(`watch.triggered`):
|
|
230
|
+
- 若事件包含 `delivery_mode = "offline_replay"` 或 `replayed = true`,表示这是用户离线期间触发后补发的消息
|
|
231
|
+
- `trigger_time` 表示原始触发时间,`replayed_at` 表示补发时间
|
|
232
|
+
- 向用户描述时应明确区分:例如“这条是离线期间触发,现已补发到当前会话”
|
|
233
|
+
|
|
191
234
|
## 调用判定规则
|
|
192
235
|
|
|
193
236
|
只有在用户明确表达以下意图时调用插件:
|
|
@@ -303,11 +346,34 @@ await runtime.queryTickerData({
|
|
|
303
346
|
|
|
304
347
|
`openclaw` 渠道必传,`email` / `call` / `sms` / `dingtalk` 可选。如用户没明确说明使用邮件(email)、电话/外呼(call)、短信(sms)、钉钉(dingtalk)通知提醒,则只需要传入`openclaw`渠道。
|
|
305
348
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
-
|
|
349
|
+
### 插件侧联系人记忆与自动补全(openclaw-plugin-node)
|
|
350
|
+
|
|
351
|
+
宿主在调用 `watch_create`、`notify_*` 等工具时,应把**当前发送者上下文**传入 `context`(与 `openclaw` 路由合并所用上下文一致)。与 OpenClaw `buildSenderContext` 对齐时,推荐优先使用嵌套对象 **`context.senderContext`**(或顶层同名字段),例如:
|
|
352
|
+
|
|
353
|
+
- `senderContext.channel`(或 `channel` / `channelId`):来源渠道(钉钉建议为 `dingtalk`)
|
|
354
|
+
- `senderContext.senderId`(或顶层 `senderId` / `sender_id`):发送者唯一标识;钉钉下通常与 `cas_id` 一致
|
|
355
|
+
- 仍兼容:`senderDingtalkId`、`event.metadata.senderDingtalkId` 等历史字段
|
|
356
|
+
- `senderContext.senderName` / `senderName` / `displayName`:展示名(可用于外呼 `customer_name` 兜底)
|
|
357
|
+
|
|
358
|
+
插件内等价解析函数为 `extractSenderContext`;`buildSenderContext` 为其别名(单参 `buildSenderContext(context)` 即可)。
|
|
359
|
+
|
|
360
|
+
插件会在发 `watch.create` / `notify.send` 前:
|
|
361
|
+
|
|
362
|
+
1. 按 `channel:sender_id` 读写独立 JSON(默认路径:`~/.openclaw/workspace/memory/watch-notify-contacts.json`,可通过插件配置 `contactsStorePath` 或环境变量 `OPENCLAW_CONTACTS_STORE_PATH` 覆盖)。
|
|
363
|
+
2. 对 **sms / dingtalk / email / call** 缺省字段用该发送者已保存的默认值补全。
|
|
364
|
+
3. **钉钉**:若当前会话渠道为 `dingtalk` 且未提供 `cas_id`,则用当前发送者 ID 作为默认 `cas_id` 并写入记忆。
|
|
365
|
+
4. **外呼**:`customer_name` 优先用户本轮输入 → 记忆 → 发送者展示名。
|
|
366
|
+
|
|
367
|
+
若补全后仍缺必填项(如从未提供过手机号),bridge 仍会报错,此时应追问用户;用户一旦提供有效值,插件会更新记忆,后续同发送者无需重复填写。
|
|
368
|
+
|
|
369
|
+
向用户确认时**避免完整回显手机号**,可用尾号提示。
|
|
370
|
+
|
|
371
|
+
用户选择了某个通知渠道时,**最终**发往 bridge 的 payload 仍须满足各渠道必填项(插件会先按上文规则补全;补全后仍缺的,由 Agent 追问用户补齐):
|
|
372
|
+
|
|
373
|
+
- 选择 `email`:`channel_configs.email.to_address/template_id/title/content`
|
|
374
|
+
- 选择 `call`:`channel_configs.call.phone/customer_name/condition`
|
|
375
|
+
- 选择 `sms`:`channel_configs.sms.receiver(或phone)/template_id/content`
|
|
376
|
+
- 选择 `dingtalk`:`channel_configs.dingtalk.cas_id/template_id/msg_type/content`
|
|
311
377
|
|
|
312
378
|
### email 参数(channel_configs.email)
|
|
313
379
|
- `to_address`:收件人邮箱(必填,缺失不可创建/不可发送)
|
|
@@ -164,6 +164,10 @@ export class OpenClawBridgeClient extends EventEmitter {
|
|
|
164
164
|
return this._request('watch.pause', { strategy_id: strategyId });
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
async listWatches(payload = {}) {
|
|
168
|
+
return this._request('watch.list', payload || {});
|
|
169
|
+
}
|
|
170
|
+
|
|
167
171
|
async deleteWatch(strategyId) {
|
|
168
172
|
return this._request('watch.delete', { strategy_id: strategyId });
|
|
169
173
|
}
|
|
@@ -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,
|
|
@@ -150,4 +163,8 @@ export class OpenClawPluginAdapter {
|
|
|
150
163
|
async remove(strategyId) {
|
|
151
164
|
return this.client.deleteWatch(strategyId);
|
|
152
165
|
}
|
|
166
|
+
|
|
167
|
+
async listWatches(params = {}) {
|
|
168
|
+
return this.client.listWatches(params || {});
|
|
169
|
+
}
|
|
153
170
|
}
|
|
@@ -2,6 +2,19 @@ import os from 'node:os';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
|
|
5
|
+
export function resolveContactsStorePath({ env = process.env, pluginConfig = {} } = {}) {
|
|
6
|
+
const explicit = pick(
|
|
7
|
+
pluginConfig,
|
|
8
|
+
['contactsStorePath', 'contacts_store_path'],
|
|
9
|
+
pick(env, ['OPENCLAW_CONTACTS_STORE_PATH'])
|
|
10
|
+
);
|
|
11
|
+
if (explicit) {
|
|
12
|
+
return String(explicit).trim();
|
|
13
|
+
}
|
|
14
|
+
const homeDir = pick(env, ['HOME', 'USERPROFILE']) || os.homedir();
|
|
15
|
+
return path.join(String(homeDir), '.openclaw', 'workspace', 'memory', 'watch-notify-contacts.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
import { ProcessLock } from '../runtime/lock/ProcessLock.js';
|
|
6
19
|
|
|
7
20
|
const DEFAULT_BASE_WS_URL = 'wss://glanceup-pre.100credit.cn';
|
|
@@ -65,6 +78,7 @@ export function resolveRuntimeConfig({ env = process.env, pluginConfig = {} } =
|
|
|
65
78
|
baseWsUrl,
|
|
66
79
|
token,
|
|
67
80
|
lockDir,
|
|
68
|
-
lockKey
|
|
81
|
+
lockKey,
|
|
82
|
+
contactsStorePath: resolveContactsStorePath({ env, pluginConfig })
|
|
69
83
|
};
|
|
70
84
|
}
|
|
@@ -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,5 +1,10 @@
|
|
|
1
|
-
import { resolveRuntimeConfig } from '../config/runtime-config.js';
|
|
1
|
+
import { resolveRuntimeConfig, resolveContactsStorePath } from '../config/runtime-config.js';
|
|
2
|
+
import { extractOpenclawRoutingFromRecord, deriveOpenclawRouting } from '../openclawRouting.js';
|
|
2
3
|
import { BridgeRuntime } from '../runtime/BridgeRuntime.js';
|
|
4
|
+
import {
|
|
5
|
+
mergeAndPersistNotifyContacts,
|
|
6
|
+
mergeAndPersistWatchContacts
|
|
7
|
+
} from './watch-notify-contacts.js';
|
|
3
8
|
import { PluginDispatcher } from '../runtime/dispatchers/PluginDispatcher.js';
|
|
4
9
|
import { ProcessLock } from '../runtime/lock/ProcessLock.js';
|
|
5
10
|
|
|
@@ -65,7 +70,6 @@ function mapDemandToCreatePayload(demand = {}) {
|
|
|
65
70
|
const channelConfigs = { ...(demand.channelConfigs || {}) };
|
|
66
71
|
|
|
67
72
|
if (demand.openclawConfig) {
|
|
68
|
-
channelConfigs.openclaw = demand.openclawConfig;
|
|
69
73
|
if (!channels.includes('openclaw')) channels.push('openclaw');
|
|
70
74
|
}
|
|
71
75
|
if (demand.emailConfig) {
|
|
@@ -85,7 +89,20 @@ function mapDemandToCreatePayload(demand = {}) {
|
|
|
85
89
|
if (!channels.includes('dingtalk')) channels.push('dingtalk');
|
|
86
90
|
}
|
|
87
91
|
if (!channels.includes('openclaw')) channels.unshift('openclaw');
|
|
88
|
-
|
|
92
|
+
|
|
93
|
+
const existingOpenclaw =
|
|
94
|
+
channelConfigs.openclaw && typeof channelConfigs.openclaw === 'object'
|
|
95
|
+
? { ...channelConfigs.openclaw }
|
|
96
|
+
: {};
|
|
97
|
+
const explicitOpenclaw =
|
|
98
|
+
demand.openclawConfig && typeof demand.openclawConfig === 'object'
|
|
99
|
+
? { ...demand.openclawConfig }
|
|
100
|
+
: {};
|
|
101
|
+
channelConfigs.openclaw = {
|
|
102
|
+
...extractOpenclawRoutingFromRecord(demand),
|
|
103
|
+
...existingOpenclaw,
|
|
104
|
+
...explicitOpenclaw
|
|
105
|
+
};
|
|
89
106
|
|
|
90
107
|
return {
|
|
91
108
|
product_code: demand.productCode || demand.product_code,
|
|
@@ -101,7 +118,34 @@ function mapDemandToCreatePayload(demand = {}) {
|
|
|
101
118
|
};
|
|
102
119
|
}
|
|
103
120
|
|
|
104
|
-
function
|
|
121
|
+
function mergeOpenclawChannelConfig(payload = {}, context = {}) {
|
|
122
|
+
const merged = { ...(payload || {}) };
|
|
123
|
+
const channelConfigs = { ...(merged.channel_configs || {}) };
|
|
124
|
+
const openclawConfig = { ...(channelConfigs.openclaw || {}) };
|
|
125
|
+
const routing = deriveOpenclawRouting({ params: merged, context });
|
|
126
|
+
|
|
127
|
+
if (Object.keys(routing).length === 0) {
|
|
128
|
+
return merged;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
channelConfigs.openclaw = {
|
|
132
|
+
...openclawConfig,
|
|
133
|
+
...routing
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const channels = Array.isArray(merged.channels)
|
|
137
|
+
? merged.channels
|
|
138
|
+
.filter((x) => typeof x === 'string' && x.trim())
|
|
139
|
+
.map((x) => x.trim().toLowerCase())
|
|
140
|
+
: [];
|
|
141
|
+
if (!channels.includes('openclaw')) channels.unshift('openclaw');
|
|
142
|
+
|
|
143
|
+
merged.channels = channels;
|
|
144
|
+
merged.channel_configs = channelConfigs;
|
|
145
|
+
return merged;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildControlApi(startupPromise, contactsStorePath) {
|
|
105
149
|
return {
|
|
106
150
|
async queryTickerData(query = {}) {
|
|
107
151
|
const runtime = await getReadyRuntime(startupPromise);
|
|
@@ -117,39 +161,70 @@ function buildControlApi(startupPromise) {
|
|
|
117
161
|
product_type: productType
|
|
118
162
|
});
|
|
119
163
|
},
|
|
120
|
-
async createWatch(payload = {}) {
|
|
164
|
+
async createWatch(payload = {}, context = {}) {
|
|
121
165
|
const runtime = await getReadyRuntime(startupPromise);
|
|
122
|
-
|
|
166
|
+
const normalized = mergeOpenclawChannelConfig(payload, context);
|
|
167
|
+
const filled = await mergeAndPersistWatchContacts(
|
|
168
|
+
contactsStorePath,
|
|
169
|
+
normalized,
|
|
170
|
+
context
|
|
171
|
+
);
|
|
172
|
+
return runtime.request('watch.create', filled);
|
|
123
173
|
},
|
|
124
|
-
async sendNotification(input = {}) {
|
|
174
|
+
async sendNotification(input = {}, context = {}) {
|
|
125
175
|
const runtime = await getReadyRuntime(startupPromise);
|
|
126
|
-
const
|
|
176
|
+
const ch = String(input.channel ?? '')
|
|
177
|
+
.trim()
|
|
178
|
+
.toLowerCase();
|
|
179
|
+
const allowed = new Set(['sms', 'email', 'call', 'dingtalk']);
|
|
180
|
+
if (!ch || !allowed.has(ch)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
'notify.send requires input.channel to be one of: sms, email, call, dingtalk'
|
|
183
|
+
);
|
|
184
|
+
}
|
|
127
185
|
const payload = { ...(input.payload || {}) };
|
|
186
|
+
const merged = await mergeAndPersistNotifyContacts(
|
|
187
|
+
contactsStorePath,
|
|
188
|
+
ch,
|
|
189
|
+
payload,
|
|
190
|
+
context
|
|
191
|
+
);
|
|
128
192
|
return runtime.request('notify.send', {
|
|
129
|
-
...
|
|
130
|
-
channel
|
|
193
|
+
...merged,
|
|
194
|
+
channel: ch
|
|
131
195
|
});
|
|
132
196
|
},
|
|
133
|
-
async sendSms(payload = {}) {
|
|
134
|
-
return this.sendNotification({ channel: 'sms', payload });
|
|
197
|
+
async sendSms(payload = {}, context = {}) {
|
|
198
|
+
return this.sendNotification({ channel: 'sms', payload }, context);
|
|
135
199
|
},
|
|
136
|
-
async sendCall(payload = {}) {
|
|
137
|
-
return this.sendNotification({ channel: 'call', payload });
|
|
200
|
+
async sendCall(payload = {}, context = {}) {
|
|
201
|
+
return this.sendNotification({ channel: 'call', payload }, context);
|
|
138
202
|
},
|
|
139
|
-
async sendEmail(payload = {}) {
|
|
140
|
-
return this.sendNotification({ channel: 'email', payload });
|
|
203
|
+
async sendEmail(payload = {}, context = {}) {
|
|
204
|
+
return this.sendNotification({ channel: 'email', payload }, context);
|
|
141
205
|
},
|
|
142
|
-
async sendDingtalk(payload = {}) {
|
|
143
|
-
return this.sendNotification({ channel: 'dingtalk', payload });
|
|
206
|
+
async sendDingtalk(payload = {}, context = {}) {
|
|
207
|
+
return this.sendNotification({ channel: 'dingtalk', payload }, context);
|
|
144
208
|
},
|
|
145
|
-
async submitWatchDemand(demand = {}) {
|
|
209
|
+
async submitWatchDemand(demand = {}, context = {}) {
|
|
146
210
|
const runtime = await getReadyRuntime(startupPromise);
|
|
147
|
-
|
|
211
|
+
const payload = mapDemandToCreatePayload(demand);
|
|
212
|
+
const normalized = mergeOpenclawChannelConfig(payload, context);
|
|
213
|
+
const filled = await mergeAndPersistWatchContacts(
|
|
214
|
+
contactsStorePath,
|
|
215
|
+
normalized,
|
|
216
|
+
context
|
|
217
|
+
);
|
|
218
|
+
return runtime.request('watch.create', filled);
|
|
148
219
|
},
|
|
149
220
|
async pauseWatch(strategyId) {
|
|
150
221
|
const runtime = await getReadyRuntime(startupPromise);
|
|
151
222
|
return runtime.request('watch.pause', { strategy_id: strategyId });
|
|
152
223
|
},
|
|
224
|
+
async listWatches(payload = {}) {
|
|
225
|
+
const runtime = await getReadyRuntime(startupPromise);
|
|
226
|
+
return runtime.request('watch.list', payload || {});
|
|
227
|
+
},
|
|
153
228
|
async activateWatch(strategyId) {
|
|
154
229
|
const runtime = await getReadyRuntime(startupPromise);
|
|
155
230
|
return runtime.request('watch.activate', { strategy_id: strategyId });
|
|
@@ -176,7 +251,8 @@ function tryRegisterTool(registerTool, name, description, parameters, handler) {
|
|
|
176
251
|
parameters: schema,
|
|
177
252
|
inputSchema: schema,
|
|
178
253
|
handler,
|
|
179
|
-
execute: async (_toolCallId, params) =>
|
|
254
|
+
execute: async (_toolCallId, params, _onUpdate, context) =>
|
|
255
|
+
handler(params || {}, { context: context || {} })
|
|
180
256
|
};
|
|
181
257
|
const meta = {
|
|
182
258
|
name,
|
|
@@ -234,7 +310,7 @@ function registerControlTools(api, controlApi) {
|
|
|
234
310
|
additionalProperties: true,
|
|
235
311
|
properties: {}
|
|
236
312
|
},
|
|
237
|
-
(args) => controlApi.sendSms(args || {})
|
|
313
|
+
(args, meta = {}) => controlApi.sendSms(args || {}, meta?.context || {})
|
|
238
314
|
);
|
|
239
315
|
|
|
240
316
|
tryRegisterTool(
|
|
@@ -246,7 +322,7 @@ function registerControlTools(api, controlApi) {
|
|
|
246
322
|
additionalProperties: true,
|
|
247
323
|
properties: {}
|
|
248
324
|
},
|
|
249
|
-
(args) => controlApi.sendCall(args || {})
|
|
325
|
+
(args, meta = {}) => controlApi.sendCall(args || {}, meta?.context || {})
|
|
250
326
|
);
|
|
251
327
|
|
|
252
328
|
tryRegisterTool(
|
|
@@ -258,7 +334,7 @@ function registerControlTools(api, controlApi) {
|
|
|
258
334
|
additionalProperties: true,
|
|
259
335
|
properties: {}
|
|
260
336
|
},
|
|
261
|
-
(args) => controlApi.sendEmail(args || {})
|
|
337
|
+
(args, meta = {}) => controlApi.sendEmail(args || {}, meta?.context || {})
|
|
262
338
|
);
|
|
263
339
|
|
|
264
340
|
tryRegisterTool(
|
|
@@ -270,7 +346,7 @@ function registerControlTools(api, controlApi) {
|
|
|
270
346
|
additionalProperties: true,
|
|
271
347
|
properties: {}
|
|
272
348
|
},
|
|
273
|
-
(args) => controlApi.sendDingtalk(args || {})
|
|
349
|
+
(args, meta = {}) => controlApi.sendDingtalk(args || {}, meta?.context || {})
|
|
274
350
|
);
|
|
275
351
|
|
|
276
352
|
tryRegisterTool(
|
|
@@ -289,7 +365,7 @@ function registerControlTools(api, controlApi) {
|
|
|
289
365
|
channel_configs: { type: 'object' }
|
|
290
366
|
}
|
|
291
367
|
},
|
|
292
|
-
(args) => controlApi.createWatch(args || {})
|
|
368
|
+
(args, meta = {}) => controlApi.createWatch(args || {}, meta?.context || {})
|
|
293
369
|
);
|
|
294
370
|
|
|
295
371
|
const strategySchema = {
|
|
@@ -301,6 +377,22 @@ function registerControlTools(api, controlApi) {
|
|
|
301
377
|
}
|
|
302
378
|
};
|
|
303
379
|
|
|
380
|
+
tryRegisterTool(
|
|
381
|
+
registerTool,
|
|
382
|
+
'watch_list',
|
|
383
|
+
'List watch strategies for current user',
|
|
384
|
+
{
|
|
385
|
+
type: 'object',
|
|
386
|
+
additionalProperties: true,
|
|
387
|
+
properties: {
|
|
388
|
+
status: { type: 'string' },
|
|
389
|
+
product_code: { type: 'string' },
|
|
390
|
+
productCode: { type: 'string' }
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
(args) => controlApi.listWatches(args || {})
|
|
394
|
+
);
|
|
395
|
+
|
|
304
396
|
tryRegisterTool(
|
|
305
397
|
registerTool,
|
|
306
398
|
'watch_pause',
|
|
@@ -340,6 +432,8 @@ const plugin = {
|
|
|
340
432
|
api?.config?.plugins?.glanceBridge?.config ||
|
|
341
433
|
{};
|
|
342
434
|
|
|
435
|
+
const contactsStorePath = resolveContactsStorePath({ pluginConfig });
|
|
436
|
+
|
|
343
437
|
const startupPromise = startPluginRuntime({
|
|
344
438
|
runtime: api?.runtime,
|
|
345
439
|
pluginConfig
|
|
@@ -348,7 +442,7 @@ const plugin = {
|
|
|
348
442
|
api?.runtime?.logger?.error?.(`[openclaw-glance-plugin] runtime start failed: ${err.message}`);
|
|
349
443
|
});
|
|
350
444
|
|
|
351
|
-
const controlApi = buildControlApi(startupPromise);
|
|
445
|
+
const controlApi = buildControlApi(startupPromise, contactsStorePath);
|
|
352
446
|
api.glanceBridge = controlApi;
|
|
353
447
|
registerControlTools(api, controlApi);
|
|
354
448
|
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
deriveOpenclawRouting,
|
|
6
|
+
mergeContextMetadata,
|
|
7
|
+
pickFirstSenderIdentifier,
|
|
8
|
+
pickFirstString,
|
|
9
|
+
unwrapSenderContextObject
|
|
10
|
+
} from '../openclawRouting.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 同一联系人文件读改写串行化,避免并发丢更新。
|
|
14
|
+
* 仅单进程内有效;多进程同时写仍可能竞态,需外部协调或独占部署。
|
|
15
|
+
*/
|
|
16
|
+
const contactFileQueues = new Map();
|
|
17
|
+
|
|
18
|
+
export function runContactsFileSerialized(filePath, fn) {
|
|
19
|
+
const key = path.resolve(String(filePath));
|
|
20
|
+
const prev = contactFileQueues.get(key) || Promise.resolve();
|
|
21
|
+
const next = prev.then(
|
|
22
|
+
() => fn(),
|
|
23
|
+
() => fn()
|
|
24
|
+
);
|
|
25
|
+
contactFileQueues.set(key, next);
|
|
26
|
+
return next.finally(() => {
|
|
27
|
+
if (contactFileQueues.get(key) === next) {
|
|
28
|
+
contactFileQueues.delete(key);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 从 OpenClaw / 宿主上下文解析发送者维度主键(channel:sender_id)。
|
|
35
|
+
* 与 OpenClaw `buildSenderContext` 对齐:优先 `context.senderContext`,其次顶层 `senderId`;
|
|
36
|
+
* 仍兼容 `senderDingtalkId`、metadata、路由字段等历史来源。
|
|
37
|
+
*/
|
|
38
|
+
export function extractSenderContext({ context = {}, params = {} } = {}) {
|
|
39
|
+
const metadata = mergeContextMetadata(context);
|
|
40
|
+
const sc = unwrapSenderContextObject(context);
|
|
41
|
+
const routing = deriveOpenclawRouting({ params, context });
|
|
42
|
+
|
|
43
|
+
const channelRaw = pickFirstString(
|
|
44
|
+
sc.channel,
|
|
45
|
+
sc.sourceChannel,
|
|
46
|
+
sc.source_channel,
|
|
47
|
+
routing.channel,
|
|
48
|
+
params?.source_channel,
|
|
49
|
+
metadata?.channel,
|
|
50
|
+
metadata?.channelId,
|
|
51
|
+
context?.channel,
|
|
52
|
+
context?.channelId
|
|
53
|
+
);
|
|
54
|
+
const channel = String(channelRaw || 'unknown')
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.trim();
|
|
57
|
+
|
|
58
|
+
const senderId = pickFirstSenderIdentifier(
|
|
59
|
+
sc.senderId,
|
|
60
|
+
sc.sender_id,
|
|
61
|
+
sc.userId,
|
|
62
|
+
sc.user_id,
|
|
63
|
+
sc.casId,
|
|
64
|
+
sc.cas_id,
|
|
65
|
+
context.senderId,
|
|
66
|
+
context.sender_id,
|
|
67
|
+
context.userId,
|
|
68
|
+
context.user_id,
|
|
69
|
+
context.casId,
|
|
70
|
+
context.cas_id,
|
|
71
|
+
metadata.senderId,
|
|
72
|
+
metadata.sender_id,
|
|
73
|
+
metadata.senderDingtalkId,
|
|
74
|
+
metadata.sender_dingtalk_id,
|
|
75
|
+
context.senderDingtalkId,
|
|
76
|
+
metadata.userId,
|
|
77
|
+
metadata.user_id,
|
|
78
|
+
metadata.openId,
|
|
79
|
+
params.senderId,
|
|
80
|
+
params.sender_id
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const senderName = pickFirstString(
|
|
84
|
+
sc.senderName,
|
|
85
|
+
sc.sender_name,
|
|
86
|
+
sc.displayName,
|
|
87
|
+
sc.display_name,
|
|
88
|
+
sc.nickname,
|
|
89
|
+
metadata.senderName,
|
|
90
|
+
metadata.sender_name,
|
|
91
|
+
metadata.displayName,
|
|
92
|
+
metadata.display_name,
|
|
93
|
+
metadata.nickname,
|
|
94
|
+
context.senderName,
|
|
95
|
+
context.displayName
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (!senderId) {
|
|
99
|
+
return { channel, senderId: null, senderName: senderName || null, senderKey: null };
|
|
100
|
+
}
|
|
101
|
+
const id = String(senderId).trim();
|
|
102
|
+
const senderKey = `${channel}:${id}`;
|
|
103
|
+
return { channel, senderId: id, senderName: senderName || null, senderKey };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 与 OpenClaw 侧 `buildSenderContext(context)` 单参用法兼容的别名。
|
|
108
|
+
*/
|
|
109
|
+
export function buildSenderContext(context = {}, params = {}) {
|
|
110
|
+
return extractSenderContext({ context, params });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @returns {string|null} 仅数字,含简单 +86 剥离 */
|
|
114
|
+
export function normalizePhone(raw) {
|
|
115
|
+
if (raw == null) return null;
|
|
116
|
+
let s = String(raw).trim().replace(/\s+/g, '');
|
|
117
|
+
if (!s) return null;
|
|
118
|
+
s = s.replace(/^\+86/, '').replace(/^86/, '');
|
|
119
|
+
const digits = s.replace(/\D/g, '');
|
|
120
|
+
if (digits.length === 11) return digits;
|
|
121
|
+
if (digits.length === 13 && digits.startsWith('86')) return digits.slice(2);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @returns {string|null} 非空 trim,用于钉钉 cas_id 等 */
|
|
126
|
+
export function normalizeCasId(raw) {
|
|
127
|
+
if (raw == null) return null;
|
|
128
|
+
const s = String(raw).trim();
|
|
129
|
+
return s || null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @returns {string|null} 合法邮箱则返回 trim 后地址 */
|
|
133
|
+
export function normalizeEmail(raw) {
|
|
134
|
+
if (raw == null) return null;
|
|
135
|
+
const s = String(raw).trim();
|
|
136
|
+
if (!s) return null;
|
|
137
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)) return null;
|
|
138
|
+
return s;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function emptyContactsDoc() {
|
|
142
|
+
return { version: 1, senders: {} };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function backupCorruptContactsFile(filePath, raw) {
|
|
146
|
+
const dir = path.dirname(filePath);
|
|
147
|
+
const base = path.basename(filePath);
|
|
148
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
149
|
+
const backupPath = path.join(dir, `${base}.corrupt.${stamp}.bak`);
|
|
150
|
+
await mkdir(dir, { recursive: true });
|
|
151
|
+
await writeFile(backupPath, raw, 'utf8');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function loadContactsFile(filePath) {
|
|
155
|
+
try {
|
|
156
|
+
const raw = await readFile(filePath, 'utf8');
|
|
157
|
+
let data;
|
|
158
|
+
try {
|
|
159
|
+
data = JSON.parse(raw);
|
|
160
|
+
} catch (_parseErr) {
|
|
161
|
+
await backupCorruptContactsFile(filePath, raw);
|
|
162
|
+
return emptyContactsDoc();
|
|
163
|
+
}
|
|
164
|
+
if (!data || typeof data !== 'object') return emptyContactsDoc();
|
|
165
|
+
if (!data.senders || typeof data.senders !== 'object') {
|
|
166
|
+
data.senders = {};
|
|
167
|
+
}
|
|
168
|
+
if (data.version == null) data.version = 1;
|
|
169
|
+
return data;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) {
|
|
172
|
+
return emptyContactsDoc();
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function saveContactsFile(filePath, doc) {
|
|
179
|
+
const dir = path.dirname(filePath);
|
|
180
|
+
await mkdir(dir, { recursive: true });
|
|
181
|
+
const text = `${JSON.stringify(doc, null, 2)}\n`;
|
|
182
|
+
await writeFile(filePath, text, 'utf8');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getEntry(doc, senderKey) {
|
|
186
|
+
if (!senderKey || !doc?.senders) return null;
|
|
187
|
+
return doc.senders[senderKey] || null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function touchEntry(doc, senderKey, senderId, senderName) {
|
|
191
|
+
if (!senderKey) return;
|
|
192
|
+
if (!doc.senders) doc.senders = {};
|
|
193
|
+
const prev = doc.senders[senderKey] || {
|
|
194
|
+
sender_id: senderId,
|
|
195
|
+
sender_name: senderName,
|
|
196
|
+
defaults: {},
|
|
197
|
+
updated_at: null
|
|
198
|
+
};
|
|
199
|
+
doc.senders[senderKey] = {
|
|
200
|
+
...prev,
|
|
201
|
+
sender_id: senderId || prev.sender_id,
|
|
202
|
+
sender_name: senderName || prev.sender_name || undefined
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 宿主未带会话 channel 时,用已合并的 openclaw 路由里的 channel 推断记忆主键,减少 unknown:<id> 串号。
|
|
208
|
+
*/
|
|
209
|
+
function refineSessionFromOpenclawConfig(ctx, openclaw) {
|
|
210
|
+
let { channel, senderId, senderName, senderKey } = ctx;
|
|
211
|
+
if (channel !== 'unknown' || !senderId) return ctx;
|
|
212
|
+
if (!openclaw || typeof openclaw !== 'object') return ctx;
|
|
213
|
+
const hint = pickFirstString(
|
|
214
|
+
openclaw.channel,
|
|
215
|
+
openclaw.source_channel,
|
|
216
|
+
openclaw.sourceChannel
|
|
217
|
+
);
|
|
218
|
+
if (!hint) return ctx;
|
|
219
|
+
const c = String(hint).toLowerCase().trim();
|
|
220
|
+
if (!c || c === 'unknown') return ctx;
|
|
221
|
+
const id = String(senderId).trim();
|
|
222
|
+
return {
|
|
223
|
+
channel: c,
|
|
224
|
+
senderId: id,
|
|
225
|
+
senderName,
|
|
226
|
+
senderKey: `${c}:${id}`
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 合并 watch.create / submitWatchDemand 的 channel_configs。
|
|
232
|
+
* 缺字段时按记忆与钉钉 sender_id 规则补全;有 senderKey 时回写记忆。
|
|
233
|
+
*/
|
|
234
|
+
export async function mergeAndPersistWatchContacts(filePath, mergedPayload, context) {
|
|
235
|
+
const channels = Array.isArray(mergedPayload?.channels)
|
|
236
|
+
? mergedPayload.channels.map((x) => String(x).toLowerCase().trim()).filter(Boolean)
|
|
237
|
+
: [];
|
|
238
|
+
if (channels.length === 0) {
|
|
239
|
+
return mergedPayload;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return runContactsFileSerialized(filePath, async () => {
|
|
243
|
+
let senderCtx = extractSenderContext({
|
|
244
|
+
context,
|
|
245
|
+
params: mergedPayload || {}
|
|
246
|
+
});
|
|
247
|
+
senderCtx = refineSessionFromOpenclawConfig(
|
|
248
|
+
senderCtx,
|
|
249
|
+
mergedPayload?.channel_configs?.openclaw
|
|
250
|
+
);
|
|
251
|
+
const { senderKey, senderId, senderName, channel: sessionChannel } = senderCtx;
|
|
252
|
+
|
|
253
|
+
let doc = await loadContactsFile(filePath);
|
|
254
|
+
const entry = getEntry(doc, senderKey);
|
|
255
|
+
const defaults = entry?.defaults && typeof entry.defaults === 'object' ? entry.defaults : {};
|
|
256
|
+
|
|
257
|
+
const channelConfigs = { ...(mergedPayload.channel_configs || {}) };
|
|
258
|
+
|
|
259
|
+
const isDingtalkSession = sessionChannel === 'dingtalk';
|
|
260
|
+
|
|
261
|
+
if (channels.includes('sms')) {
|
|
262
|
+
const sms = { ...(channelConfigs.sms && typeof channelConfigs.sms === 'object' ? channelConfigs.sms : {}) };
|
|
263
|
+
let phone = normalizePhone(pickFirstString(sms.receiver, sms.phone));
|
|
264
|
+
if (!phone && defaults.sms?.phone) {
|
|
265
|
+
phone = normalizePhone(defaults.sms.phone);
|
|
266
|
+
if (phone) {
|
|
267
|
+
sms.receiver = phone;
|
|
268
|
+
if (sms.phone != null) delete sms.phone;
|
|
269
|
+
}
|
|
270
|
+
} else if (phone) {
|
|
271
|
+
sms.receiver = phone;
|
|
272
|
+
}
|
|
273
|
+
channelConfigs.sms = sms;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (channels.includes('dingtalk')) {
|
|
277
|
+
const dt = {
|
|
278
|
+
...(channelConfigs.dingtalk && typeof channelConfigs.dingtalk === 'object'
|
|
279
|
+
? channelConfigs.dingtalk
|
|
280
|
+
: {})
|
|
281
|
+
};
|
|
282
|
+
const casId =
|
|
283
|
+
normalizeCasId(pickFirstString(dt.cas_id, dt.casId)) ||
|
|
284
|
+
normalizeCasId(defaults.dingtalk?.cas_id) ||
|
|
285
|
+
(isDingtalkSession ? normalizeCasId(senderId) : null);
|
|
286
|
+
if (casId) {
|
|
287
|
+
dt.cas_id = casId;
|
|
288
|
+
if (dt.casId != null) delete dt.casId;
|
|
289
|
+
}
|
|
290
|
+
channelConfigs.dingtalk = dt;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (channels.includes('email')) {
|
|
294
|
+
const em = {
|
|
295
|
+
...(channelConfigs.email && typeof channelConfigs.email === 'object' ? channelConfigs.email : {})
|
|
296
|
+
};
|
|
297
|
+
const addr =
|
|
298
|
+
normalizeEmail(pickFirstString(em.to_address, em.toAddress)) ||
|
|
299
|
+
normalizeEmail(defaults.email?.to_address);
|
|
300
|
+
if (addr) {
|
|
301
|
+
em.to_address = addr;
|
|
302
|
+
if (em.toAddress != null) delete em.toAddress;
|
|
303
|
+
}
|
|
304
|
+
channelConfigs.email = em;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (channels.includes('call')) {
|
|
308
|
+
const ca = {
|
|
309
|
+
...(channelConfigs.call && typeof channelConfigs.call === 'object' ? channelConfigs.call : {})
|
|
310
|
+
};
|
|
311
|
+
let phone = normalizePhone(pickFirstString(ca.phone, ca.receiver));
|
|
312
|
+
if (!phone && defaults.call?.phone) {
|
|
313
|
+
phone = normalizePhone(defaults.call.phone);
|
|
314
|
+
}
|
|
315
|
+
if (phone) ca.phone = phone;
|
|
316
|
+
|
|
317
|
+
let name = pickFirstString(ca.customer_name, ca.customerName);
|
|
318
|
+
if (!name && defaults.call?.customer_name) {
|
|
319
|
+
name = String(defaults.call.customer_name).trim();
|
|
320
|
+
}
|
|
321
|
+
if (!name && senderName) {
|
|
322
|
+
name = String(senderName).trim();
|
|
323
|
+
}
|
|
324
|
+
if (name) {
|
|
325
|
+
ca.customer_name = name;
|
|
326
|
+
if (ca.customerName != null) delete ca.customerName;
|
|
327
|
+
}
|
|
328
|
+
channelConfigs.call = ca;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const next = { ...mergedPayload, channel_configs: channelConfigs };
|
|
332
|
+
|
|
333
|
+
if (senderKey && senderId) {
|
|
334
|
+
touchEntry(doc, senderKey, senderId, senderName);
|
|
335
|
+
const patch = {};
|
|
336
|
+
const smsCfg = channelConfigs.sms;
|
|
337
|
+
const pSms = normalizePhone(pickFirstString(smsCfg?.receiver, smsCfg?.phone));
|
|
338
|
+
if (pSms) patch.sms = { phone: pSms };
|
|
339
|
+
|
|
340
|
+
const dtCfg = channelConfigs.dingtalk;
|
|
341
|
+
const cas = normalizeCasId(pickFirstString(dtCfg?.cas_id, dtCfg?.casId));
|
|
342
|
+
if (cas) patch.dingtalk = { cas_id: cas };
|
|
343
|
+
|
|
344
|
+
const emCfg = channelConfigs.email;
|
|
345
|
+
const to = normalizeEmail(pickFirstString(emCfg?.to_address, emCfg?.toAddress));
|
|
346
|
+
if (to) patch.email = { to_address: to };
|
|
347
|
+
|
|
348
|
+
const caCfg = channelConfigs.call;
|
|
349
|
+
const cPhone = normalizePhone(pickFirstString(caCfg?.phone, caCfg?.receiver));
|
|
350
|
+
const cName = pickFirstString(caCfg?.customer_name, caCfg?.customerName);
|
|
351
|
+
if (cPhone || cName) {
|
|
352
|
+
patch.call = {
|
|
353
|
+
...(cPhone ? { phone: cPhone } : {}),
|
|
354
|
+
...(cName ? { customer_name: String(cName).trim() } : {})
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (Object.keys(patch).length > 0) {
|
|
359
|
+
const cur = doc.senders[senderKey];
|
|
360
|
+
cur.defaults = { ...(cur.defaults || {}), ...patch };
|
|
361
|
+
cur.updated_at = new Date().toISOString();
|
|
362
|
+
await saveContactsFile(filePath, doc);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return next;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 合并 notify.send 单渠道 payload。
|
|
372
|
+
*/
|
|
373
|
+
export async function mergeAndPersistNotifyContacts(filePath, notifyChannel, payload, context) {
|
|
374
|
+
const ch = String(notifyChannel || '')
|
|
375
|
+
.toLowerCase()
|
|
376
|
+
.trim();
|
|
377
|
+
const allowedNotify = new Set(['sms', 'email', 'call', 'dingtalk']);
|
|
378
|
+
if (!ch || !allowedNotify.has(ch)) {
|
|
379
|
+
return { ...(payload && typeof payload === 'object' ? payload : {}) };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return runContactsFileSerialized(filePath, async () => {
|
|
383
|
+
let senderCtx = extractSenderContext({
|
|
384
|
+
context,
|
|
385
|
+
params: payload || {}
|
|
386
|
+
});
|
|
387
|
+
const routingHint = deriveOpenclawRouting({ params: payload || {}, context });
|
|
388
|
+
senderCtx = refineSessionFromOpenclawConfig(
|
|
389
|
+
senderCtx,
|
|
390
|
+
routingHint.channel ? { channel: routingHint.channel } : null
|
|
391
|
+
);
|
|
392
|
+
const { senderKey, senderId, senderName, channel: sessionChannel } = senderCtx;
|
|
393
|
+
const out = { ...(payload && typeof payload === 'object' ? payload : {}) };
|
|
394
|
+
|
|
395
|
+
let doc = await loadContactsFile(filePath);
|
|
396
|
+
const entry = getEntry(doc, senderKey);
|
|
397
|
+
const defaults = entry?.defaults && typeof entry.defaults === 'object' ? entry.defaults : {};
|
|
398
|
+
const isDingtalkSession = sessionChannel === 'dingtalk';
|
|
399
|
+
|
|
400
|
+
if (ch === 'sms') {
|
|
401
|
+
let phone = normalizePhone(pickFirstString(out.receiver, out.phone));
|
|
402
|
+
if (!phone && defaults.sms?.phone) {
|
|
403
|
+
phone = normalizePhone(defaults.sms.phone);
|
|
404
|
+
}
|
|
405
|
+
if (phone) {
|
|
406
|
+
out.receiver = phone;
|
|
407
|
+
if (out.phone != null) delete out.phone;
|
|
408
|
+
}
|
|
409
|
+
} else if (ch === 'dingtalk') {
|
|
410
|
+
const casId =
|
|
411
|
+
normalizeCasId(pickFirstString(out.cas_id, out.casId)) ||
|
|
412
|
+
normalizeCasId(defaults.dingtalk?.cas_id) ||
|
|
413
|
+
(isDingtalkSession ? normalizeCasId(senderId) : null);
|
|
414
|
+
if (casId) {
|
|
415
|
+
out.cas_id = casId;
|
|
416
|
+
if (out.casId != null) delete out.casId;
|
|
417
|
+
}
|
|
418
|
+
} else if (ch === 'email') {
|
|
419
|
+
const addr =
|
|
420
|
+
normalizeEmail(pickFirstString(out.to_address, out.toAddress)) ||
|
|
421
|
+
normalizeEmail(defaults.email?.to_address);
|
|
422
|
+
if (addr) {
|
|
423
|
+
out.to_address = addr;
|
|
424
|
+
if (out.toAddress != null) delete out.toAddress;
|
|
425
|
+
}
|
|
426
|
+
} else if (ch === 'call') {
|
|
427
|
+
let phone = normalizePhone(pickFirstString(out.phone, out.receiver));
|
|
428
|
+
if (!phone && defaults.call?.phone) {
|
|
429
|
+
phone = normalizePhone(defaults.call.phone);
|
|
430
|
+
}
|
|
431
|
+
if (phone) out.phone = phone;
|
|
432
|
+
|
|
433
|
+
let name = pickFirstString(out.customer_name, out.customerName);
|
|
434
|
+
if (!name && defaults.call?.customer_name) {
|
|
435
|
+
name = String(defaults.call.customer_name).trim();
|
|
436
|
+
}
|
|
437
|
+
if (!name && senderName) {
|
|
438
|
+
name = String(senderName).trim();
|
|
439
|
+
}
|
|
440
|
+
if (name) {
|
|
441
|
+
out.customer_name = name;
|
|
442
|
+
if (out.customerName != null) delete out.customerName;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (senderKey && senderId) {
|
|
447
|
+
touchEntry(doc, senderKey, senderId, senderName);
|
|
448
|
+
const patch = {};
|
|
449
|
+
if (ch === 'sms') {
|
|
450
|
+
const p = normalizePhone(pickFirstString(out.receiver, out.phone));
|
|
451
|
+
if (p) patch.sms = { phone: p };
|
|
452
|
+
} else if (ch === 'dingtalk') {
|
|
453
|
+
const c = normalizeCasId(pickFirstString(out.cas_id, out.casId));
|
|
454
|
+
if (c) patch.dingtalk = { cas_id: c };
|
|
455
|
+
} else if (ch === 'email') {
|
|
456
|
+
const t = normalizeEmail(pickFirstString(out.to_address, out.toAddress));
|
|
457
|
+
if (t) patch.email = { to_address: t };
|
|
458
|
+
} else if (ch === 'call') {
|
|
459
|
+
const p = normalizePhone(pickFirstString(out.phone, out.receiver));
|
|
460
|
+
const n = pickFirstString(out.customer_name, out.customerName);
|
|
461
|
+
if (p || n) {
|
|
462
|
+
patch.call = {
|
|
463
|
+
...(p ? { phone: p } : {}),
|
|
464
|
+
...(n ? { customer_name: String(n).trim() } : {})
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (Object.keys(patch).length > 0) {
|
|
469
|
+
const cur = doc.senders[senderKey];
|
|
470
|
+
cur.defaults = { ...(cur.defaults || {}), ...patch };
|
|
471
|
+
cur.updated_at = new Date().toISOString();
|
|
472
|
+
await saveContactsFile(filePath, doc);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return out;
|
|
477
|
+
});
|
|
478
|
+
}
|
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
function pickFirstString(...values) {
|
|
2
|
+
for (const value of values) {
|
|
3
|
+
if (typeof value === 'string' && value.trim()) {
|
|
4
|
+
return value.trim();
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractRoutingFromTriggeredEvent(event) {
|
|
11
|
+
const payload = event?.payload || {};
|
|
12
|
+
const openclaw = payload?.channel_configs?.openclaw || payload?.openclaw || {};
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
channel: pickFirstString(
|
|
16
|
+
payload?.channel,
|
|
17
|
+
payload?.source_channel,
|
|
18
|
+
openclaw?.channel,
|
|
19
|
+
openclaw?.source_channel
|
|
20
|
+
),
|
|
21
|
+
accountId: pickFirstString(payload?.account_id, openclaw?.account_id),
|
|
22
|
+
sessionKey: pickFirstString(
|
|
23
|
+
payload?.session_key,
|
|
24
|
+
payload?.sessionKey,
|
|
25
|
+
openclaw?.session_key,
|
|
26
|
+
openclaw?.sessionKey
|
|
27
|
+
),
|
|
28
|
+
conversationId: pickFirstString(
|
|
29
|
+
payload?.conversation_id,
|
|
30
|
+
payload?.conversationId,
|
|
31
|
+
payload?.chat_id,
|
|
32
|
+
payload?.chatId,
|
|
33
|
+
openclaw?.conversation_id,
|
|
34
|
+
openclaw?.conversationId,
|
|
35
|
+
openclaw?.chat_id,
|
|
36
|
+
openclaw?.chatId
|
|
37
|
+
)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
1
41
|
export class PluginDispatcher {
|
|
2
42
|
constructor({ runtime }) {
|
|
3
43
|
this.runtime = runtime;
|
|
@@ -7,12 +47,22 @@ export class PluginDispatcher {
|
|
|
7
47
|
if (!this.runtime?.dispatchReply) {
|
|
8
48
|
return;
|
|
9
49
|
}
|
|
10
|
-
|
|
50
|
+
|
|
51
|
+
const routing = extractRoutingFromTriggeredEvent(event);
|
|
52
|
+
const dispatchPayload = {
|
|
11
53
|
text: event?.payload?.message || '',
|
|
12
54
|
metadata: {
|
|
13
55
|
source: 'watch.triggered',
|
|
14
|
-
event
|
|
56
|
+
event,
|
|
57
|
+
routing
|
|
15
58
|
}
|
|
16
|
-
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (routing.channel) dispatchPayload.channel = routing.channel;
|
|
62
|
+
if (routing.accountId) dispatchPayload.accountId = routing.accountId;
|
|
63
|
+
if (routing.sessionKey) dispatchPayload.sessionKey = routing.sessionKey;
|
|
64
|
+
if (routing.conversationId) dispatchPayload.conversationId = routing.conversationId;
|
|
65
|
+
|
|
66
|
+
await this.runtime.dispatchReply(dispatchPayload);
|
|
17
67
|
}
|
|
18
68
|
}
|