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.
@@ -13,6 +13,10 @@
13
13
  },
14
14
  "lockDir": {
15
15
  "type": "string"
16
+ },
17
+ "contactsStorePath": {
18
+ "type": "string",
19
+ "description": "Path to watch-notify-contacts.json (sender defaults for sms/dingtalk/email/call)"
16
20
  }
17
21
  },
18
22
  "required": ["token"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-glance-plugin",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "OpenClaw plugin client for ticker-monitor openclaw-bridge",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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
- - 选择 `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`
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
- if (!channelConfigs.openclaw) channelConfigs.openclaw = {};
106
+
107
+ const existingOpenclaw =
108
+ channelConfigs.openclaw && typeof channelConfigs.openclaw === 'object'
109
+ ? { ...channelConfigs.openclaw }
110
+ : {};
111
+ const explicitOpenclaw =
112
+ demand.openclawConfig && typeof demand.openclawConfig === 'object'
113
+ ? { ...demand.openclawConfig }
114
+ : {};
115
+ channelConfigs.openclaw = {
116
+ ...extractOpenclawRoutingFromRecord(demand),
117
+ ...existingOpenclaw,
118
+ ...explicitOpenclaw
119
+ };
107
120
 
108
121
  const payload = {
109
122
  product_code: demand.productCode,
@@ -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
+ }
@@ -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
- if (!channelConfigs.openclaw) channelConfigs.openclaw = {};
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
- return runtime.request('watch.create', normalized);
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 channel = input.channel;
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
- ...payload,
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
- return runtime.request('watch.create', mergeOpenclawChannelConfig(payload, context));
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
+ }