openclaw-glance-plugin 0.1.16 → 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/openclaw.plugin.json +4 -0
- package/package.json +1 -1
- package/skills/glance-watch/SKILL.md +47 -6
- package/src/OpenClawPluginAdapter.js +15 -2
- package/src/config/runtime-config.js +15 -1
- package/src/openclawRouting.js +174 -0
- package/src/plugin/index.js +67 -69
- package/src/plugin/watch-notify-contacts.js +478 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -77,6 +77,19 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
77
77
|
- `channels`(默认至少包含 `openclaw`)
|
|
78
78
|
- 对应渠道配置(`channel_configs.email/call/sms/dingtalk`)
|
|
79
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
|
+
|
|
80
93
|
固定模板(必须按此结构构造,字段名不要改):
|
|
81
94
|
|
|
82
95
|
```javascript
|
|
@@ -94,7 +107,12 @@ description: 智能盯盘插件,用于监控A股、港股、比特币等金融
|
|
|
94
107
|
channels: ['openclaw', 'dingtalk', 'sms'],
|
|
95
108
|
// 注意:必须是对象,不要传 JSON 字符串
|
|
96
109
|
channel_configs: {
|
|
97
|
-
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
|
+
},
|
|
98
116
|
dingtalk: {
|
|
99
117
|
cas_id: 'jinguo.xie',
|
|
100
118
|
template_id: 3,
|
|
@@ -328,11 +346,34 @@ await runtime.queryTickerData({
|
|
|
328
346
|
|
|
329
347
|
`openclaw` 渠道必传,`email` / `call` / `sms` / `dingtalk` 可选。如用户没明确说明使用邮件(email)、电话/外呼(call)、短信(sms)、钉钉(dingtalk)通知提醒,则只需要传入`openclaw`渠道。
|
|
330
348
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
-
|
|
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`
|
|
336
377
|
|
|
337
378
|
### email 参数(channel_configs.email)
|
|
338
379
|
- `to_address`:收件人邮箱(必填,缺失不可创建/不可发送)
|
|
@@ -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,
|
|
@@ -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
|
|
|
@@ -56,52 +61,6 @@ async function getReadyRuntime(startupPromise) {
|
|
|
56
61
|
return activeRuntime;
|
|
57
62
|
}
|
|
58
63
|
|
|
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
64
|
function mapDemandToCreatePayload(demand = {}) {
|
|
106
65
|
const channels = Array.isArray(demand.channels)
|
|
107
66
|
? demand.channels
|
|
@@ -111,7 +70,6 @@ function mapDemandToCreatePayload(demand = {}) {
|
|
|
111
70
|
const channelConfigs = { ...(demand.channelConfigs || {}) };
|
|
112
71
|
|
|
113
72
|
if (demand.openclawConfig) {
|
|
114
|
-
channelConfigs.openclaw = demand.openclawConfig;
|
|
115
73
|
if (!channels.includes('openclaw')) channels.push('openclaw');
|
|
116
74
|
}
|
|
117
75
|
if (demand.emailConfig) {
|
|
@@ -131,7 +89,20 @@ function mapDemandToCreatePayload(demand = {}) {
|
|
|
131
89
|
if (!channels.includes('dingtalk')) channels.push('dingtalk');
|
|
132
90
|
}
|
|
133
91
|
if (!channels.includes('openclaw')) channels.unshift('openclaw');
|
|
134
|
-
|
|
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
|
+
};
|
|
135
106
|
|
|
136
107
|
return {
|
|
137
108
|
product_code: demand.productCode || demand.product_code,
|
|
@@ -174,7 +145,7 @@ function mergeOpenclawChannelConfig(payload = {}, context = {}) {
|
|
|
174
145
|
return merged;
|
|
175
146
|
}
|
|
176
147
|
|
|
177
|
-
function buildControlApi(startupPromise) {
|
|
148
|
+
function buildControlApi(startupPromise, contactsStorePath) {
|
|
178
149
|
return {
|
|
179
150
|
async queryTickerData(query = {}) {
|
|
180
151
|
const runtime = await getReadyRuntime(startupPromise);
|
|
@@ -193,33 +164,58 @@ function buildControlApi(startupPromise) {
|
|
|
193
164
|
async createWatch(payload = {}, context = {}) {
|
|
194
165
|
const runtime = await getReadyRuntime(startupPromise);
|
|
195
166
|
const normalized = mergeOpenclawChannelConfig(payload, context);
|
|
196
|
-
|
|
167
|
+
const filled = await mergeAndPersistWatchContacts(
|
|
168
|
+
contactsStorePath,
|
|
169
|
+
normalized,
|
|
170
|
+
context
|
|
171
|
+
);
|
|
172
|
+
return runtime.request('watch.create', filled);
|
|
197
173
|
},
|
|
198
|
-
async sendNotification(input = {}) {
|
|
174
|
+
async sendNotification(input = {}, context = {}) {
|
|
199
175
|
const runtime = await getReadyRuntime(startupPromise);
|
|
200
|
-
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
|
+
}
|
|
201
185
|
const payload = { ...(input.payload || {}) };
|
|
186
|
+
const merged = await mergeAndPersistNotifyContacts(
|
|
187
|
+
contactsStorePath,
|
|
188
|
+
ch,
|
|
189
|
+
payload,
|
|
190
|
+
context
|
|
191
|
+
);
|
|
202
192
|
return runtime.request('notify.send', {
|
|
203
|
-
...
|
|
204
|
-
channel
|
|
193
|
+
...merged,
|
|
194
|
+
channel: ch
|
|
205
195
|
});
|
|
206
196
|
},
|
|
207
|
-
async sendSms(payload = {}) {
|
|
208
|
-
return this.sendNotification({ channel: 'sms', payload });
|
|
197
|
+
async sendSms(payload = {}, context = {}) {
|
|
198
|
+
return this.sendNotification({ channel: 'sms', payload }, context);
|
|
209
199
|
},
|
|
210
|
-
async sendCall(payload = {}) {
|
|
211
|
-
return this.sendNotification({ channel: 'call', payload });
|
|
200
|
+
async sendCall(payload = {}, context = {}) {
|
|
201
|
+
return this.sendNotification({ channel: 'call', payload }, context);
|
|
212
202
|
},
|
|
213
|
-
async sendEmail(payload = {}) {
|
|
214
|
-
return this.sendNotification({ channel: 'email', payload });
|
|
203
|
+
async sendEmail(payload = {}, context = {}) {
|
|
204
|
+
return this.sendNotification({ channel: 'email', payload }, context);
|
|
215
205
|
},
|
|
216
|
-
async sendDingtalk(payload = {}) {
|
|
217
|
-
return this.sendNotification({ channel: 'dingtalk', payload });
|
|
206
|
+
async sendDingtalk(payload = {}, context = {}) {
|
|
207
|
+
return this.sendNotification({ channel: 'dingtalk', payload }, context);
|
|
218
208
|
},
|
|
219
209
|
async submitWatchDemand(demand = {}, context = {}) {
|
|
220
210
|
const runtime = await getReadyRuntime(startupPromise);
|
|
221
211
|
const payload = mapDemandToCreatePayload(demand);
|
|
222
|
-
|
|
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);
|
|
223
219
|
},
|
|
224
220
|
async pauseWatch(strategyId) {
|
|
225
221
|
const runtime = await getReadyRuntime(startupPromise);
|
|
@@ -314,7 +310,7 @@ function registerControlTools(api, controlApi) {
|
|
|
314
310
|
additionalProperties: true,
|
|
315
311
|
properties: {}
|
|
316
312
|
},
|
|
317
|
-
(args) => controlApi.sendSms(args || {})
|
|
313
|
+
(args, meta = {}) => controlApi.sendSms(args || {}, meta?.context || {})
|
|
318
314
|
);
|
|
319
315
|
|
|
320
316
|
tryRegisterTool(
|
|
@@ -326,7 +322,7 @@ function registerControlTools(api, controlApi) {
|
|
|
326
322
|
additionalProperties: true,
|
|
327
323
|
properties: {}
|
|
328
324
|
},
|
|
329
|
-
(args) => controlApi.sendCall(args || {})
|
|
325
|
+
(args, meta = {}) => controlApi.sendCall(args || {}, meta?.context || {})
|
|
330
326
|
);
|
|
331
327
|
|
|
332
328
|
tryRegisterTool(
|
|
@@ -338,7 +334,7 @@ function registerControlTools(api, controlApi) {
|
|
|
338
334
|
additionalProperties: true,
|
|
339
335
|
properties: {}
|
|
340
336
|
},
|
|
341
|
-
(args) => controlApi.sendEmail(args || {})
|
|
337
|
+
(args, meta = {}) => controlApi.sendEmail(args || {}, meta?.context || {})
|
|
342
338
|
);
|
|
343
339
|
|
|
344
340
|
tryRegisterTool(
|
|
@@ -350,7 +346,7 @@ function registerControlTools(api, controlApi) {
|
|
|
350
346
|
additionalProperties: true,
|
|
351
347
|
properties: {}
|
|
352
348
|
},
|
|
353
|
-
(args) => controlApi.sendDingtalk(args || {})
|
|
349
|
+
(args, meta = {}) => controlApi.sendDingtalk(args || {}, meta?.context || {})
|
|
354
350
|
);
|
|
355
351
|
|
|
356
352
|
tryRegisterTool(
|
|
@@ -436,6 +432,8 @@ const plugin = {
|
|
|
436
432
|
api?.config?.plugins?.glanceBridge?.config ||
|
|
437
433
|
{};
|
|
438
434
|
|
|
435
|
+
const contactsStorePath = resolveContactsStorePath({ pluginConfig });
|
|
436
|
+
|
|
439
437
|
const startupPromise = startPluginRuntime({
|
|
440
438
|
runtime: api?.runtime,
|
|
441
439
|
pluginConfig
|
|
@@ -444,7 +442,7 @@ const plugin = {
|
|
|
444
442
|
api?.runtime?.logger?.error?.(`[openclaw-glance-plugin] runtime start failed: ${err.message}`);
|
|
445
443
|
});
|
|
446
444
|
|
|
447
|
-
const controlApi = buildControlApi(startupPromise);
|
|
445
|
+
const controlApi = buildControlApi(startupPromise, contactsStorePath);
|
|
448
446
|
api.glanceBridge = controlApi;
|
|
449
447
|
registerControlTools(api, controlApi);
|
|
450
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
|
+
}
|