@openclaw-channel/socket-chat 1.0.5 → 1.0.7

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/src/inbound.ts CHANGED
@@ -1,272 +1,36 @@
1
- import type { ChannelGatewayContext } from "openclaw/plugin-sdk";
2
1
  import {
3
- resolveAllowlistMatchByCandidates,
4
- createNormalizedOutboundDeliverer,
5
2
  buildMediaPayload,
6
- resolveChannelMediaMaxBytes,
3
+ createChannelPairingController,
4
+ deliverFormattedTextWithAttachments,
7
5
  detectMime,
8
- } from "openclaw/plugin-sdk";
6
+ dispatchInboundReplyWithBase,
7
+ resolveAllowlistMatchByCandidates,
8
+ resolveChannelMediaMaxBytes,
9
+ type OutboundReplyPayload,
10
+ } from "./runtime-api.js";
9
11
  import { resolveSocketChatAccount, type CoreConfig } from "./config.js";
10
- import type { ResolvedSocketChatAccount } from "./config.js";
11
12
  import type { SocketChatInboundMessage } from "./types.js";
13
+ import { getSocketChatRuntime } from "./runtime.js";
12
14
 
13
- // ---------------------------------------------------------------------------
14
- // 安全检查 — DM 访问控制
15
- // ---------------------------------------------------------------------------
16
-
17
- /**
18
- * 执行 DM 安全策略检查,返回是否允许此消息触发 AI。
19
- *
20
- * 策略:
21
- * - "open":任意发送者均可触发
22
- * - "allowlist":senderId 必须在 allowFrom 列表中
23
- * - "pairing"(默认):senderId 必须在 allowFrom 或 pairing 已批准列表中;
24
- * 否则发送配对请求消息并阻止本次触发
25
- *
26
- * 群组消息跳过 DM 策略检查(群组有独立的 requireMention 控制)。
27
- */
28
- async function enforceDmAccess(params: {
29
- msg: SocketChatInboundMessage;
30
- accountId: string;
31
- account: ResolvedSocketChatAccount;
32
- ctx: ChannelGatewayContext<ResolvedSocketChatAccount>;
33
- log: LogSink;
34
- sendReply: (text: string) => Promise<void>;
35
- }): Promise<boolean> {
36
- const { msg, accountId, account, ctx, log, sendReply } = params;
37
- const channelRuntime = ctx.channelRuntime!;
38
-
39
- // 群组消息不走 DM 策略
40
- if (msg.isGroup) {
41
- return true;
42
- }
43
-
44
- const dmPolicy = account.config.dmPolicy ?? "pairing";
45
-
46
- if (dmPolicy === "open") {
47
- return true;
48
- }
49
-
50
- // 读取配置中的静态 allowFrom 白名单
51
- const configAllowFrom = (account.config.allowFrom ?? []).map((e) =>
52
- e.replace(/^(socket-chat|sc):/i, "").toLowerCase(),
53
- );
54
-
55
- // 读取动态 pairing 已批准名单(存储在 ~/.openclaw/credentials/ 下)
56
- const pairingAllowFrom: string[] = await channelRuntime.pairing.readAllowFromStore({
57
- channel: "socket-chat",
58
- accountId,
59
- });
60
-
61
- // 合并两个白名单进行匹配
62
- const mergedAllowFrom = [
63
- ...configAllowFrom,
64
- ...pairingAllowFrom.map((e: string) => e.toLowerCase()),
65
- ];
66
-
67
- const senderId = msg.senderId.toLowerCase();
68
- const senderName = msg.senderName?.toLowerCase();
69
- const match = resolveAllowlistMatchByCandidates({
70
- allowList: mergedAllowFrom,
71
- candidates: [
72
- { value: senderId, source: "id" as const },
73
- ...(senderName ? [{ value: senderName, source: "name" as const }] : []),
74
- ],
75
- });
76
-
77
- // 通配符("*")直接放行
78
- if (mergedAllowFrom.includes("*") || match.allowed) {
79
- return true;
80
- }
81
-
82
- // allowlist 策略:不在白名单中,静默拒绝
83
- if (dmPolicy === "allowlist") {
84
- log.warn(
85
- `[${accountId}] blocked sender ${msg.senderId} (dmPolicy=allowlist, not in allowFrom)`,
86
- );
87
- return false;
88
- }
89
-
90
- // pairing 策略:创建配对请求,并向发送者发送提示
91
- try {
92
- const { code, created } = await channelRuntime.pairing.upsertPairingRequest({
93
- channel: "socket-chat",
94
- id: msg.senderId,
95
- accountId,
96
- meta: {
97
- senderName: msg.senderName || undefined,
98
- },
99
- });
100
-
101
- if (created) {
102
- log.info(
103
- `[${accountId}] pairing request created for ${msg.senderId} (code=${code})`,
104
- );
105
- const pairingMessage = channelRuntime.pairing.buildPairingReply({
106
- channel: "socket-chat",
107
- idLine: `Your Socket Chat user id: ${msg.senderId}`,
108
- code,
109
- });
110
- await sendReply(pairingMessage);
111
- }
112
- } catch (err) {
113
- log.warn(
114
- `[${accountId}] failed to create pairing request for ${msg.senderId}: ` +
115
- `${err instanceof Error ? err.message : String(err)}`,
116
- );
117
- }
118
-
119
- return false;
120
- }
15
+ const CHANNEL_ID = "socket-chat" as const;
121
16
 
122
17
  // ---------------------------------------------------------------------------
123
- // 群组访问控制第一层(groupId 级别)+ 第二层(sender 级别)
18
+ // Group access track notified groups to avoid repeat "not allowed" messages
124
19
  // ---------------------------------------------------------------------------
125
20
 
126
21
  /**
127
- * 记录已发送过"群未授权"提醒的群,键为 `${accountId}:${groupId}`。
128
- * 进程内只提醒一次,避免每条消息都重复发送。
22
+ * Records groups that have already received a "not allowed" notification.
23
+ * Keys are `${accountId}:${groupId}`. In-process only; resets on restart.
129
24
  */
130
25
  const notifiedGroups = new Set<string>();
131
26
 
132
- /** 仅供测试使用:重置一次性提醒状态。 */
27
+ /** Only for tests — resets the in-process notification state. */
133
28
  export function _resetNotifiedGroupsForTest(): void {
134
29
  notifiedGroups.clear();
135
30
  }
136
31
 
137
- /**
138
- * 规范化群/发送者 ID,去除前缀、空格、转小写,便于白名单对比。
139
- */
140
- function normalizeSocketChatId(raw: string): string {
141
- return raw.replace(/^(socket-chat|sc):/i, "").trim().toLowerCase();
142
- }
143
-
144
- /**
145
- * 第一层:检查当前群是否被允许触发 AI。
146
- *
147
- * - groupPolicy="open"(默认):所有群均可触发
148
- * - groupPolicy="allowlist":仅 groups 列表中的群可触发,不在列表的群收到一次提醒
149
- * - groupPolicy="disabled":禁止所有群消息触发 AI
150
- *
151
- * 返回 `{ allowed, notify }`:
152
- * - allowed=false + notify=true 表示首次拦截,调用方应发送提醒消息
153
- * - allowed=false + notify=false 表示已提醒过,静默忽略
154
- */
155
- function checkGroupAccess(params: {
156
- groupId: string;
157
- account: ResolvedSocketChatAccount;
158
- log: LogSink;
159
- accountId: string;
160
- }): { allowed: boolean; notify: boolean } {
161
- const { groupId, account, log, accountId } = params;
162
- const groupPolicy = account.config.groupPolicy ?? "open";
163
-
164
- if (groupPolicy === "disabled") {
165
- log.info(`[${accountId}] group msg blocked (groupPolicy=disabled)`);
166
- return { allowed: false, notify: false };
167
- }
168
-
169
- if (groupPolicy === "open") {
170
- return { allowed: true, notify: false };
171
- }
172
-
173
- // allowlist:检查 groupId 是否在 groups 名单中
174
- const groups = (account.config.groups ?? []).map(normalizeSocketChatId);
175
- if (groups.length === 0) {
176
- log.info(`[${accountId}] group ${groupId} blocked (groupPolicy=allowlist, groups is empty)`);
177
- return { allowed: false, notify: false };
178
- }
179
-
180
- const normalizedGroupId = normalizeSocketChatId(groupId);
181
- if (groups.includes("*") || groups.includes(normalizedGroupId)) {
182
- return { allowed: true, notify: false };
183
- }
184
-
185
- log.info(`[${accountId}] group ${groupId} not in groups allowlist (groupPolicy=allowlist)`);
186
-
187
- // 判断是否需要发送一次性提醒
188
- const notifyKey = `${accountId}:${groupId}`;
189
- if (notifiedGroups.has(notifyKey)) {
190
- return { allowed: false, notify: false };
191
- }
192
- notifiedGroups.add(notifyKey);
193
- return { allowed: false, notify: true };
194
- }
195
-
196
- /**
197
- * 第二层:检查群内发送者是否被允许触发 AI。
198
- *
199
- * 仅当 groupAllowFrom 非空时生效;为空则允许群内所有成员。
200
- */
201
- function checkGroupSenderAccess(params: {
202
- senderId: string;
203
- senderName: string | undefined;
204
- account: ResolvedSocketChatAccount;
205
- log: LogSink;
206
- accountId: string;
207
- }): boolean {
208
- const { senderId, senderName, account, log, accountId } = params;
209
- const groupAllowFrom = (account.config.groupAllowFrom ?? []).map(normalizeSocketChatId);
210
-
211
- // 未配置 sender 白名单 → 不限制
212
- if (groupAllowFrom.length === 0) return true;
213
- if (groupAllowFrom.includes("*")) return true;
214
-
215
- const normalizedSenderId = normalizeSocketChatId(senderId);
216
- const normalizedSenderName = senderName ? senderName.trim().toLowerCase() : undefined;
217
-
218
- const allowed =
219
- groupAllowFrom.includes(normalizedSenderId) ||
220
- (normalizedSenderName !== undefined && groupAllowFrom.includes(normalizedSenderName));
221
-
222
- if (!allowed) {
223
- log.info(
224
- `[${accountId}] group sender ${senderId} not in groupAllowFrom`,
225
- );
226
- }
227
- return allowed;
228
- }
229
-
230
32
  // ---------------------------------------------------------------------------
231
- // 群组消息 @提及检查
232
- // ---------------------------------------------------------------------------
233
-
234
- /**
235
- * 检查群组消息是否需要 @提及 bot。
236
- * 从配置读取 requireMention,默认为 true(需要提及)。
237
- *
238
- * 提及检测:消息内容包含 robotId 即视为提及。
239
- */
240
- function checkGroupMention(params: {
241
- msg: SocketChatInboundMessage;
242
- account: ResolvedSocketChatAccount;
243
- robotId: string;
244
- log: LogSink;
245
- accountId: string;
246
- }): boolean {
247
- const { msg, account, robotId, log, accountId } = params;
248
- if (!msg.isGroup) return true;
249
-
250
- const requireMention = account.config.requireMention !== false; // 默认 true
251
- if (!requireMention) return true;
252
-
253
- // 优先使用平台传来的精确判断(on-claw-message.js 已计算好 isMention)
254
- // fallback:检查消息内容中是否包含 @robotId(不做宽泛的 content.includes(robotId) 以避免误判)
255
- const mentioned =
256
- msg.isGroupMention === true ||
257
- msg.content.includes(`@${robotId}`);
258
-
259
- if (!mentioned) {
260
- log.debug?.(
261
- `[${accountId}] group msg ${msg.messageId} skipped (requireMention=true, not mentioned)`,
262
- );
263
- return false;
264
- }
265
- return true;
266
- }
267
-
268
- // ---------------------------------------------------------------------------
269
- // 主入站处理函数
33
+ // Type definitions
270
34
  // ---------------------------------------------------------------------------
271
35
 
272
36
  type LogSink = {
@@ -276,111 +40,156 @@ type LogSink = {
276
40
  debug?: (m: string) => void;
277
41
  };
278
42
 
43
+ // ---------------------------------------------------------------------------
44
+ // Main inbound handler
45
+ // ---------------------------------------------------------------------------
46
+
279
47
  /**
280
- * 处理 MQTT 入站消息:
281
- * 1. 安全策略检查(allowlist / pairing
282
- * 2. 群组 @提及检查
283
- * 3. 媒体文件下载到本地
284
- * 4. 路由 + 记录 session
285
- * 5. 派发 AI 回复
48
+ * Handle an inbound MQTT message:
49
+ * 1. DM / group access control (allowlist / pairing challenge)
50
+ * 2. Group @mention check
51
+ * 3. Media file localization (HTTP URL or base64 → local path)
52
+ * 4. Route resolution
53
+ * 5. Dispatch AI reply via dispatchInboundReplyWithBase
286
54
  */
287
- export async function handleInboundMessage(params: {
55
+ export async function handleSocketChatInbound(params: {
288
56
  msg: SocketChatInboundMessage;
289
57
  accountId: string;
290
- ctx: ChannelGatewayContext<ResolvedSocketChatAccount>;
58
+ config: CoreConfig;
291
59
  log: LogSink;
292
- /** 发送文字回复给原始发送者(用于 pairing 提示消息) */
60
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
61
+ /** Send a text reply to the original sender (used for pairing challenge messages) */
293
62
  sendReply: (to: string, text: string) => Promise<void>;
294
63
  }): Promise<void> {
295
- const { msg, accountId, ctx, log, sendReply } = params;
296
- const channelRuntime = ctx.channelRuntime;
64
+ const { msg, accountId, config, log, statusSink, sendReply } = params;
297
65
 
298
- if (!channelRuntime) {
299
- log.warn(`[socket-chat:${accountId}] channelRuntime not available — cannot dispatch AI reply`);
300
- return;
301
- }
302
-
303
- // 跳过空消息(媒体消息 content 可能为空,但有 url 时不跳过)
66
+ // Skip empty messages (media-only messages may have empty content but a url)
304
67
  if (!msg.content?.trim() && !msg.url) {
305
68
  log.debug?.(`[${accountId}] skip empty message ${msg.messageId}`);
306
69
  return;
307
70
  }
308
71
 
309
- // 解析当前账号配置(用于安全策略)
310
- const account = resolveSocketChatAccount(ctx.cfg as CoreConfig, accountId);
72
+ statusSink?.({ lastInboundAt: msg.timestamp });
311
73
 
312
- // ---- 1. DM 安全策略检查 ----
74
+ const core = getSocketChatRuntime();
75
+ const pairing = createChannelPairingController({ core, channel: CHANNEL_ID, accountId });
76
+ const account = resolveSocketChatAccount(config, accountId);
313
77
  const replyTarget = msg.isGroup ? `group:${msg.groupId}` : msg.senderId;
314
- const allowed = await enforceDmAccess({
315
- msg,
316
- accountId,
317
- account,
318
- ctx,
319
- log,
320
- sendReply: (text) => sendReply(replyTarget, text),
321
- });
322
- if (!allowed) {
323
- return;
78
+
79
+ // ---- DM access control ----
80
+ if (!msg.isGroup) {
81
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
82
+
83
+ if (dmPolicy !== "open") {
84
+ const configAllowFrom = (account.config.allowFrom ?? []).map((e) =>
85
+ e.replace(/^(socket-chat|sc):/i, "").toLowerCase(),
86
+ );
87
+ const storeAllowFrom = (await pairing.readAllowFromStore()).map((e: string) =>
88
+ e.toLowerCase(),
89
+ );
90
+ const mergedAllowFrom = [...configAllowFrom, ...storeAllowFrom];
91
+
92
+ const senderId = msg.senderId.toLowerCase();
93
+ const senderName = msg.senderName?.toLowerCase();
94
+ const match = resolveAllowlistMatchByCandidates({
95
+ allowList: mergedAllowFrom,
96
+ candidates: [
97
+ { value: senderId, source: "id" as const },
98
+ ...(senderName ? [{ value: senderName, source: "name" as const }] : []),
99
+ ],
100
+ });
101
+
102
+ if (!mergedAllowFrom.includes("*") && !match.allowed) {
103
+ if (dmPolicy === "pairing") {
104
+ await pairing.issueChallenge({
105
+ senderId: msg.senderId,
106
+ senderIdLine: `Your Socket Chat user id: ${msg.senderId}`,
107
+ meta: { name: msg.senderName || undefined },
108
+ sendPairingReply: async (text) => {
109
+ await sendReply(replyTarget, text);
110
+ statusSink?.({ lastOutboundAt: Date.now() });
111
+ },
112
+ onReplyError: (err) => {
113
+ log.warn(
114
+ `[${accountId}] pairing reply failed for ${msg.senderId}: ${String(err)}`,
115
+ );
116
+ },
117
+ });
118
+ } else {
119
+ log.warn(`[${accountId}] blocked sender ${msg.senderId} (dmPolicy=${dmPolicy})`);
120
+ }
121
+ return;
122
+ }
123
+ }
324
124
  }
325
125
 
326
- // ---- 2. 群组访问控制(第一层:群级 + 第二层:sender 级)----
126
+ // ---- Group access control ----
327
127
  if (msg.isGroup) {
328
128
  const groupId = msg.groupId ?? msg.senderId;
129
+ const groupPolicy = account.config.groupPolicy ?? "open";
329
130
 
330
- // 第一层:groupId 是否在允许列表中
331
- const groupAccess = checkGroupAccess({ groupId, account, log, accountId });
332
- if (!groupAccess.allowed) {
333
- // if (groupAccess.notify) {
334
- // await sendReply(
335
- // `group:${groupId}`,
336
- // `此群(${msg.groupName ?? groupId})未获授权使用 AI 服务。如需开启,请联系管理员将群 ID "${groupId}" 加入 groups 配置。`,
337
- // );
338
- // }
131
+ if (groupPolicy === "disabled") {
132
+ log.info(`[${accountId}] group msg blocked (groupPolicy=disabled)`);
339
133
  return;
340
134
  }
341
135
 
342
- // 第二层:群内发送者是否被允许
343
- const senderAccessAllowed = checkGroupSenderAccess({
344
- senderId: msg.senderId,
345
- senderName: msg.senderName,
346
- account,
347
- log,
348
- accountId,
349
- });
350
- if (!senderAccessAllowed) return;
351
- }
136
+ if (groupPolicy === "allowlist") {
137
+ const groups = (account.config.groups ?? []).map((g) =>
138
+ g.replace(/^(socket-chat|sc):/i, "").trim().toLowerCase(),
139
+ );
140
+ const normalizedGroupId = groupId.replace(/^(socket-chat|sc):/i, "").trim().toLowerCase();
141
+ if (!groups.includes("*") && !groups.includes(normalizedGroupId)) {
142
+ const notifyKey = `${accountId}:${groupId}`;
143
+ if (!notifiedGroups.has(notifyKey)) {
144
+ notifiedGroups.add(notifyKey);
145
+ }
146
+ log.info(`[${accountId}] group ${groupId} not in groups allowlist`);
147
+ return;
148
+ }
149
+ }
352
150
 
353
- // ---- 3. 群组 @提及检查 ----
354
- // robotId MQTT config 传入(由 mqtt-client.ts 调用时提供)
355
- // 此处从 msg.robotId 字段取(平台在每条消息中会带上 robotId)
356
- const mentionAllowed = checkGroupMention({
357
- msg,
358
- account,
359
- robotId: msg.robotId,
360
- log,
361
- accountId,
362
- });
363
- if (!mentionAllowed) {
364
- return;
151
+ // Sender-level allowlist within the group
152
+ const groupAllowFrom = (account.config.groupAllowFrom ?? []).map((e) =>
153
+ e.replace(/^(socket-chat|sc):/i, "").trim().toLowerCase(),
154
+ );
155
+ if (groupAllowFrom.length > 0 && !groupAllowFrom.includes("*")) {
156
+ const normalizedSenderId = msg.senderId
157
+ .replace(/^(socket-chat|sc):/i, "")
158
+ .trim()
159
+ .toLowerCase();
160
+ const normalizedSenderName = msg.senderName?.trim().toLowerCase();
161
+ const senderAllowed =
162
+ groupAllowFrom.includes(normalizedSenderId) ||
163
+ (normalizedSenderName !== undefined && groupAllowFrom.includes(normalizedSenderName));
164
+ if (!senderAllowed) {
165
+ log.info(`[${accountId}] group sender ${msg.senderId} not in groupAllowFrom`);
166
+ return;
167
+ }
168
+ }
169
+
170
+ // @Mention check
171
+ const requireMention = account.config.requireMention !== false;
172
+ if (requireMention) {
173
+ const mentioned = msg.isGroupMention === true || msg.content.includes(`@${msg.robotId}`);
174
+ if (!mentioned) {
175
+ log.debug?.(`[${accountId}] group msg skipped (requireMention=true, not mentioned)`);
176
+ return;
177
+ }
178
+ }
365
179
  }
366
180
 
367
181
  const chatType = msg.isGroup ? "group" : "direct";
368
182
  const peerId = msg.isGroup ? (msg.groupId ?? msg.senderId) : msg.senderId;
369
-
370
- // 判断是否为媒体消息(图片/视频/文件等)
371
183
  const isMediaMsg = !!msg.type && msg.type !== "文字";
372
- // BodyForAgent:媒体消息在 content 中已包含描述文字(如"【图片消息】\n下载链接:..."),直接使用
373
184
  const body = msg.content?.trim() || (isMediaMsg ? `<media:${msg.type}>` : "");
374
185
 
375
- // ---- 3. 媒体文件本地化 ----
376
- // 将媒体 URL 下载到本地,让 agent 能直接读取文件而不依赖外部 URL 的可用性。
377
- // HTTP/HTTPS URL:通过 fetchRemoteMedia 下载;data: URL:直接解码 base64。
378
- let resolvedMediaPayload = {};
186
+ // ---- Media localization ----
187
+ let resolvedMediaPayload: Record<string, unknown> = {};
379
188
  const mediaUrl = msg.url?.trim();
380
189
  if (mediaUrl) {
381
190
  try {
382
191
  const maxBytes = resolveChannelMediaMaxBytes({
383
- cfg: ctx.cfg,
192
+ cfg: config,
384
193
  resolveChannelLimitMb: ({ cfg }) =>
385
194
  (cfg as CoreConfig).channels?.["socket-chat"]?.mediaMaxMb,
386
195
  accountId,
@@ -390,13 +199,13 @@ export async function handleInboundMessage(params: {
390
199
  let contentTypeHint: string | undefined;
391
200
 
392
201
  if (mediaUrl.startsWith("data:")) {
393
- // data URL:解析 MIME base64 载荷
202
+ // data URL: parse MIME and base64 payload
394
203
  const commaIdx = mediaUrl.indexOf(",");
395
204
  const meta = commaIdx > 0 ? mediaUrl.slice(5, commaIdx) : "";
396
205
  const base64Data = commaIdx > 0 ? mediaUrl.slice(commaIdx + 1) : "";
397
206
  contentTypeHint = meta.split(";")[0] || undefined;
398
207
 
399
- // 大小预检(base64 字节数 × 0.75 ≈ 原始字节数)
208
+ // Size preflight (base64 chars × 0.75 ≈ raw bytes)
400
209
  const estimatedBytes = Math.ceil(base64Data.length * 0.75);
401
210
  if (maxBytes && estimatedBytes > maxBytes) {
402
211
  log.warn(
@@ -407,22 +216,14 @@ export async function handleInboundMessage(params: {
407
216
 
408
217
  buffer = Buffer.from(base64Data, "base64");
409
218
  } else {
410
- // HTTP/HTTPS URL:通过网络下载
411
- const fetched = await channelRuntime.media.fetchRemoteMedia({
412
- url: mediaUrl,
413
- filePathHint: mediaUrl,
414
- maxBytes,
415
- });
219
+ // HTTP/HTTPS URL: download via runtime media helper
220
+ const fetched = await core.channel.media.fetchRemoteMedia({ url: mediaUrl, maxBytes });
416
221
  buffer = fetched.buffer;
417
222
  contentTypeHint = fetched.contentType;
418
223
  }
419
224
 
420
- const mime = await detectMime({
421
- buffer,
422
- headerMime: contentTypeHint,
423
- filePath: mediaUrl,
424
- });
425
- const saved = await channelRuntime.media.saveMediaBuffer(
225
+ const mime = await detectMime({ buffer, headerMime: contentTypeHint, filePath: mediaUrl });
226
+ const saved = await core.channel.media.saveMediaBuffer(
426
227
  buffer,
427
228
  mime ?? contentTypeHint,
428
229
  "inbound",
@@ -432,29 +233,21 @@ export async function handleInboundMessage(params: {
432
233
  { path: saved.path, contentType: saved.contentType },
433
234
  ]);
434
235
  } catch (err) {
435
- // 下载/解码失败不阻断消息处理,仍正常派发文字部分
236
+ // Media failure must not block text dispatch
436
237
  log.warn(
437
238
  `[${accountId}] media localization failed for ${msg.messageId}: ${err instanceof Error ? err.message : String(err)}`,
438
239
  );
439
240
  }
440
241
  }
441
242
 
442
- // ---- 4. 路由 ----
443
- const route = channelRuntime.routing.resolveAgentRoute({
444
- cfg: ctx.cfg,
445
- channel: "socket-chat",
243
+ // ---- Route resolution ----
244
+ const route = core.channel.routing.resolveAgentRoute({
245
+ cfg: config,
246
+ channel: CHANNEL_ID,
446
247
  accountId,
447
- peer: {
448
- kind: msg.isGroup ? "group" : "direct",
449
- id: peerId,
450
- },
248
+ peer: { kind: msg.isGroup ? "group" : "direct", id: peerId },
451
249
  });
452
250
 
453
- log.debug?.(
454
- `[${accountId}] dispatch ${msg.messageId} from ${msg.senderId} → agent ${route.agentId}`,
455
- );
456
-
457
- // ---- 5. 构建 MsgContext ----
458
251
  const fromLabel = msg.isGroup
459
252
  ? `socket-chat:room:${peerId}`
460
253
  : `socket-chat:${msg.senderId}`;
@@ -463,7 +256,12 @@ export async function handleInboundMessage(params: {
463
256
  ? (msg.groupName ?? peerId)
464
257
  : (msg.senderName || msg.senderId);
465
258
 
466
- const ctxPayload = channelRuntime.reply.finalizeInboundContext({
259
+ const storePath = core.channel.session.resolveStorePath(
260
+ (config.session as Record<string, unknown> | undefined)?.store as string | undefined,
261
+ { agentId: route.agentId },
262
+ );
263
+
264
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
467
265
  Body: body,
468
266
  RawBody: msg.content || (msg.url ?? ""),
469
267
  BodyForAgent: body,
@@ -479,66 +277,36 @@ export async function handleInboundMessage(params: {
479
277
  ConversationLabel: conversationLabel,
480
278
  Timestamp: msg.timestamp,
481
279
  MessageSid: msg.messageId,
482
- Provider: "socket-chat",
483
- Surface: "socket-chat",
484
- OriginatingChannel: "socket-chat",
280
+ Provider: CHANNEL_ID,
281
+ Surface: CHANNEL_ID,
282
+ OriginatingChannel: CHANNEL_ID,
485
283
  OriginatingTo: toLabel,
486
- ...(msg.isGroup
487
- ? { GroupSubject: msg.groupName ?? peerId }
488
- : {}),
284
+ ...(msg.isGroup ? { GroupSubject: msg.groupName ?? peerId } : {}),
489
285
  });
490
286
 
491
- try {
492
- // ---- 6. 记录 session 元数据 ----
493
- const storePath = channelRuntime.session.resolveStorePath(undefined, {
494
- agentId: route.agentId,
495
- });
496
- await channelRuntime.session.recordInboundSession({
497
- storePath,
498
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
499
- ctx: ctxPayload,
500
- onRecordError: (err) => {
501
- log.error(`[${accountId}] failed updating session meta: ${String(err)}`);
502
- },
503
- });
504
-
505
- // 记录 inbound 活动
506
- channelRuntime.activity.record({
507
- channel: "socket-chat",
508
- accountId,
509
- direction: "inbound",
510
- });
511
-
512
- // ---- 7. 派发 AI 回复 ----
513
- // deliver 负责实际发送:框架当 originatingChannel === currentSurface 时
514
- // 不走 outbound adapter,直接调用此函数投递回复。
515
- const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
516
- const text = payload.text?.trim();
517
- if (!text) return;
518
- const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
519
- const content = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
520
- await sendReply(replyTarget, content);
521
- });
522
-
523
- await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
524
- ctx: ctxPayload,
525
- cfg: ctx.cfg,
526
- dispatcherOptions: {
527
- deliver: deliverReply,
528
- onError: (err, info) => {
529
- log.error(`[${accountId}] socket-chat ${info.kind} reply failed: ${String(err)}`);
287
+ // ---- Dispatch AI reply ----
288
+ await dispatchInboundReplyWithBase({
289
+ cfg: config,
290
+ channel: CHANNEL_ID,
291
+ accountId,
292
+ route,
293
+ storePath,
294
+ ctxPayload,
295
+ core,
296
+ deliver: async (payload: OutboundReplyPayload) => {
297
+ await deliverFormattedTextWithAttachments({
298
+ payload,
299
+ send: async ({ text }) => {
300
+ await sendReply(replyTarget, text);
301
+ statusSink?.({ lastOutboundAt: Date.now() });
530
302
  },
531
- },
532
- });
533
-
534
- // 记录 outbound 活动
535
- channelRuntime.activity.record({
536
- channel: "socket-chat",
537
- accountId,
538
- direction: "outbound",
539
- });
540
- } catch (err) {
541
- const message = err instanceof Error ? err.message : String(err);
542
- log.error(`[${accountId}] dispatch error for message ${msg.messageId}: ${message}`);
543
- }
303
+ });
304
+ },
305
+ onRecordError: (err) => {
306
+ log.error(`[${accountId}] failed updating session meta: ${String(err)}`);
307
+ },
308
+ onDispatchError: (err, info) => {
309
+ log.error(`[${accountId}] socket-chat ${info.kind} reply failed: ${String(err)}`);
310
+ },
311
+ });
544
312
  }