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 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`
@@ -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.15",
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",
@@ -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. 用户是“立即发短信/打电话/发邮件/发钉钉”意图:调用 `notify.sms` / `notify.call` / `notify.email` / `notify.dingtalk`
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
- - 选择 `email` 必须提供 `channel_configs.email.to_address/template_id/title/content`
308
- - 选择 `call` 必须提供 `channel_configs.call.phone/customer_name/condition`
309
- - 选择 `sms` 必须提供 `channel_configs.sms.receiver(或phone)/template_id/content`
310
- - 选择 `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`
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
- 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,
@@ -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
+ }
@@ -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
- 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
+ };
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 buildControlApi(startupPromise) {
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
- return runtime.request('watch.create', payload);
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 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
+ }
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
- ...payload,
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
- return runtime.request('watch.create', mapDemandToCreatePayload(demand));
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) => handler(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
- await this.runtime.dispatchReply({
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
  }