@openclaw-channel/socket-chat 1.0.6 → 1.0.8
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/channel-api.ts +3 -0
- package/index.ts +11 -12
- package/package.json +3 -3
- package/runtime-api.ts +3 -0
- package/src/__sdk-stub__.ts +26 -8
- package/src/channel.ts +391 -336
- package/src/inbound.test.ts +405 -583
- package/src/inbound.ts +175 -407
- package/src/mqtt-client.ts +66 -50
- package/src/outbound.test.ts +25 -16
- package/src/outbound.ts +31 -69
- package/src/runtime-api.ts +28 -0
- package/src/runtime.ts +8 -12
- package/tsconfig.json +1 -1
- package/vitest.config.ts +13 -7
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
|
-
|
|
3
|
+
createChannelPairingController,
|
|
4
|
+
deliverFormattedTextWithAttachments,
|
|
7
5
|
detectMime,
|
|
8
|
-
|
|
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
|
-
//
|
|
18
|
+
// Group access — track notified groups to avoid repeat "not allowed" messages
|
|
124
19
|
// ---------------------------------------------------------------------------
|
|
125
20
|
|
|
126
21
|
/**
|
|
127
|
-
*
|
|
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
|
-
*
|
|
281
|
-
* 1.
|
|
282
|
-
* 2.
|
|
283
|
-
* 3.
|
|
284
|
-
* 4.
|
|
285
|
-
* 5.
|
|
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
|
|
55
|
+
export async function handleSocketChatInbound(params: {
|
|
288
56
|
msg: SocketChatInboundMessage;
|
|
289
57
|
accountId: string;
|
|
290
|
-
|
|
58
|
+
config: CoreConfig;
|
|
291
59
|
log: LogSink;
|
|
292
|
-
|
|
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,
|
|
296
|
-
const channelRuntime = ctx.channelRuntime;
|
|
64
|
+
const { msg, accountId, config, log, statusSink, sendReply } = params;
|
|
297
65
|
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
account
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
// ----
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
// ----
|
|
376
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
// ----
|
|
443
|
-
const route =
|
|
444
|
-
cfg:
|
|
445
|
-
channel:
|
|
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
|
|
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:
|
|
483
|
-
Surface:
|
|
484
|
-
OriginatingChannel:
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
accountId
|
|
538
|
-
|
|
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
|
}
|