@openclaw-channel/socket-chat 1.0.0
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/DEVNOTES.md +219 -0
- package/README.md +215 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +44 -0
- package/src/__sdk-stub__.ts +12 -0
- package/src/api.ts +95 -0
- package/src/channel.ts +395 -0
- package/src/config-schema.test.ts +90 -0
- package/src/config-schema.ts +58 -0
- package/src/config.test.ts +318 -0
- package/src/config.ts +218 -0
- package/src/inbound.test.ts +679 -0
- package/src/inbound.ts +344 -0
- package/src/mqtt-client.ts +274 -0
- package/src/outbound.test.ts +176 -0
- package/src/outbound.ts +175 -0
- package/src/probe.ts +49 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +98 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +25 -0
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { ChannelGatewayContext } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolveAllowlistMatchByCandidates, createNormalizedOutboundDeliverer } from "openclaw/plugin-sdk";
|
|
3
|
+
import { resolveSocketChatAccount, type CoreConfig } from "./config.js";
|
|
4
|
+
import type { ResolvedSocketChatAccount } from "./config.js";
|
|
5
|
+
import type { SocketChatInboundMessage } from "./types.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// 安全检查 — DM 访问控制
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 执行 DM 安全策略检查,返回是否允许此消息触发 AI。
|
|
13
|
+
*
|
|
14
|
+
* 策略:
|
|
15
|
+
* - "open":任意发送者均可触发
|
|
16
|
+
* - "allowlist":senderId 必须在 allowFrom 列表中
|
|
17
|
+
* - "pairing"(默认):senderId 必须在 allowFrom 或 pairing 已批准列表中;
|
|
18
|
+
* 否则发送配对请求消息并阻止本次触发
|
|
19
|
+
*
|
|
20
|
+
* 群组消息跳过 DM 策略检查(群组有独立的 requireMention 控制)。
|
|
21
|
+
*/
|
|
22
|
+
async function enforceDmAccess(params: {
|
|
23
|
+
msg: SocketChatInboundMessage;
|
|
24
|
+
accountId: string;
|
|
25
|
+
account: ResolvedSocketChatAccount;
|
|
26
|
+
ctx: ChannelGatewayContext<ResolvedSocketChatAccount>;
|
|
27
|
+
log: LogSink;
|
|
28
|
+
sendReply: (text: string) => Promise<void>;
|
|
29
|
+
}): Promise<boolean> {
|
|
30
|
+
const { msg, accountId, account, ctx, log, sendReply } = params;
|
|
31
|
+
const channelRuntime = ctx.channelRuntime!;
|
|
32
|
+
|
|
33
|
+
// 群组消息不走 DM 策略
|
|
34
|
+
if (msg.isGroup) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
39
|
+
|
|
40
|
+
if (dmPolicy === "open") {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 读取配置中的静态 allowFrom 白名单
|
|
45
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map((e) =>
|
|
46
|
+
e.replace(/^(socket-chat|sc):/i, "").toLowerCase(),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// 读取动态 pairing 已批准名单(存储在 ~/.openclaw/credentials/ 下)
|
|
50
|
+
const pairingAllowFrom: string[] = await channelRuntime.pairing.readAllowFromStore({
|
|
51
|
+
channel: "socket-chat",
|
|
52
|
+
accountId,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// 合并两个白名单进行匹配
|
|
56
|
+
const mergedAllowFrom = [
|
|
57
|
+
...configAllowFrom,
|
|
58
|
+
...pairingAllowFrom.map((e: string) => e.toLowerCase()),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const senderId = msg.senderId.toLowerCase();
|
|
62
|
+
const senderName = msg.senderName?.toLowerCase();
|
|
63
|
+
const match = resolveAllowlistMatchByCandidates({
|
|
64
|
+
allowList: mergedAllowFrom,
|
|
65
|
+
candidates: [
|
|
66
|
+
{ value: senderId, source: "id" as const },
|
|
67
|
+
...(senderName ? [{ value: senderName, source: "name" as const }] : []),
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// 通配符("*")直接放行
|
|
72
|
+
if (mergedAllowFrom.includes("*") || match.allowed) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// allowlist 策略:不在白名单中,静默拒绝
|
|
77
|
+
if (dmPolicy === "allowlist") {
|
|
78
|
+
log.warn(
|
|
79
|
+
`[${accountId}] blocked sender ${msg.senderId} (dmPolicy=allowlist, not in allowFrom)`,
|
|
80
|
+
);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// pairing 策略:创建配对请求,并向发送者发送提示
|
|
85
|
+
try {
|
|
86
|
+
const { code, created } = await channelRuntime.pairing.upsertPairingRequest({
|
|
87
|
+
channel: "socket-chat",
|
|
88
|
+
id: msg.senderId,
|
|
89
|
+
accountId,
|
|
90
|
+
meta: {
|
|
91
|
+
senderName: msg.senderName || undefined,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (created) {
|
|
96
|
+
log.info(
|
|
97
|
+
`[${accountId}] pairing request created for ${msg.senderId} (code=${code})`,
|
|
98
|
+
);
|
|
99
|
+
const pairingMessage = channelRuntime.pairing.buildPairingReply({
|
|
100
|
+
channel: "socket-chat",
|
|
101
|
+
idLine: `Your Socket Chat user id: ${msg.senderId}`,
|
|
102
|
+
code,
|
|
103
|
+
});
|
|
104
|
+
await sendReply(pairingMessage);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
log.warn(
|
|
108
|
+
`[${accountId}] failed to create pairing request for ${msg.senderId}: ` +
|
|
109
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// 群组消息 @提及检查
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 检查群组消息是否需要 @提及 bot。
|
|
122
|
+
* 从配置读取 requireMention,默认为 true(需要提及)。
|
|
123
|
+
*
|
|
124
|
+
* 提及检测:消息内容包含 robotId 即视为提及。
|
|
125
|
+
*/
|
|
126
|
+
function checkGroupMention(params: {
|
|
127
|
+
msg: SocketChatInboundMessage;
|
|
128
|
+
account: ResolvedSocketChatAccount;
|
|
129
|
+
robotId: string;
|
|
130
|
+
log: LogSink;
|
|
131
|
+
accountId: string;
|
|
132
|
+
}): boolean {
|
|
133
|
+
const { msg, account, robotId, log, accountId } = params;
|
|
134
|
+
if (!msg.isGroup) return true;
|
|
135
|
+
|
|
136
|
+
const requireMention = account.config.requireMention !== false; // 默认 true
|
|
137
|
+
if (!requireMention) return true;
|
|
138
|
+
|
|
139
|
+
// 检测消息中是否包含 robotId(简单 @提及)
|
|
140
|
+
const mentioned =
|
|
141
|
+
msg.content.includes(`@${robotId}`) ||
|
|
142
|
+
msg.content.includes(robotId);
|
|
143
|
+
|
|
144
|
+
if (!mentioned) {
|
|
145
|
+
log.debug?.(
|
|
146
|
+
`[${accountId}] group msg ${msg.messageId} skipped (requireMention=true, not mentioned)`,
|
|
147
|
+
);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// 主入站处理函数
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
type LogSink = {
|
|
158
|
+
info: (m: string) => void;
|
|
159
|
+
warn: (m: string) => void;
|
|
160
|
+
error: (m: string) => void;
|
|
161
|
+
debug?: (m: string) => void;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 处理 MQTT 入站消息:
|
|
166
|
+
* 1. 安全策略检查(allowlist / pairing)
|
|
167
|
+
* 2. 群组 @提及检查
|
|
168
|
+
* 3. 路由 + 记录 session
|
|
169
|
+
* 4. 派发 AI 回复
|
|
170
|
+
*/
|
|
171
|
+
export async function handleInboundMessage(params: {
|
|
172
|
+
msg: SocketChatInboundMessage;
|
|
173
|
+
accountId: string;
|
|
174
|
+
ctx: ChannelGatewayContext<ResolvedSocketChatAccount>;
|
|
175
|
+
log: LogSink;
|
|
176
|
+
/** 发送文字回复给原始发送者(用于 pairing 提示消息) */
|
|
177
|
+
sendReply: (to: string, text: string) => Promise<void>;
|
|
178
|
+
}): Promise<void> {
|
|
179
|
+
const { msg, accountId, ctx, log, sendReply } = params;
|
|
180
|
+
const channelRuntime = ctx.channelRuntime;
|
|
181
|
+
|
|
182
|
+
if (!channelRuntime) {
|
|
183
|
+
log.warn(`[socket-chat:${accountId}] channelRuntime not available — cannot dispatch AI reply`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 跳过空消息(媒体消息 content 可能为空,但有 url 时不跳过)
|
|
188
|
+
if (!msg.content?.trim() && !msg.url) {
|
|
189
|
+
log.debug?.(`[${accountId}] skip empty message ${msg.messageId}`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 解析当前账号配置(用于安全策略)
|
|
194
|
+
const account = resolveSocketChatAccount(ctx.cfg as CoreConfig, accountId);
|
|
195
|
+
|
|
196
|
+
// ---- 1. DM 安全策略检查 ----
|
|
197
|
+
const replyTarget = msg.isGroup ? `group:${msg.groupId}` : msg.senderId;
|
|
198
|
+
const allowed = await enforceDmAccess({
|
|
199
|
+
msg,
|
|
200
|
+
accountId,
|
|
201
|
+
account,
|
|
202
|
+
ctx,
|
|
203
|
+
log,
|
|
204
|
+
sendReply: (text) => sendReply(replyTarget, text),
|
|
205
|
+
});
|
|
206
|
+
if (!allowed) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- 2. 群组 @提及检查 ----
|
|
211
|
+
// robotId 从 MQTT config 传入(由 mqtt-client.ts 调用时提供)
|
|
212
|
+
// 此处从 msg.robotId 字段取(平台在每条消息中会带上 robotId)
|
|
213
|
+
const mentionAllowed = checkGroupMention({
|
|
214
|
+
msg,
|
|
215
|
+
account,
|
|
216
|
+
robotId: msg.robotId,
|
|
217
|
+
log,
|
|
218
|
+
accountId,
|
|
219
|
+
});
|
|
220
|
+
if (!mentionAllowed) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const chatType = msg.isGroup ? "group" : "direct";
|
|
225
|
+
const peerId = msg.isGroup ? (msg.groupId ?? msg.senderId) : msg.senderId;
|
|
226
|
+
|
|
227
|
+
// 判断是否为媒体消息(图片/视频/文件等)
|
|
228
|
+
const mediaUrl = msg.url && !msg.url.startsWith("data:") ? msg.url : undefined;
|
|
229
|
+
// base64 内容不传给 agent(避免超长),仅标记 placeholder
|
|
230
|
+
const isMediaMsg = !!msg.type && msg.type !== "文字";
|
|
231
|
+
// BodyForAgent:媒体消息在 content 中已包含描述文字(如"【图片消息】\n下载链接:..."),直接使用
|
|
232
|
+
const body = msg.content?.trim() || (isMediaMsg ? `<media:${msg.type}>` : "");
|
|
233
|
+
|
|
234
|
+
// ---- 3. 路由 ----
|
|
235
|
+
const route = channelRuntime.routing.resolveAgentRoute({
|
|
236
|
+
cfg: ctx.cfg,
|
|
237
|
+
channel: "socket-chat",
|
|
238
|
+
accountId,
|
|
239
|
+
peer: {
|
|
240
|
+
kind: msg.isGroup ? "group" : "direct",
|
|
241
|
+
id: peerId,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
log.debug?.(
|
|
246
|
+
`[${accountId}] dispatch ${msg.messageId} from ${msg.senderId} → agent ${route.agentId}`,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// ---- 4. 构建 MsgContext ----
|
|
250
|
+
const fromLabel = msg.isGroup
|
|
251
|
+
? `socket-chat:room:${peerId}`
|
|
252
|
+
: `socket-chat:${msg.senderId}`;
|
|
253
|
+
const toLabel = `socket-chat:${replyTarget}`;
|
|
254
|
+
const conversationLabel = msg.isGroup
|
|
255
|
+
? (msg.groupName ?? peerId)
|
|
256
|
+
: (msg.senderName || msg.senderId);
|
|
257
|
+
|
|
258
|
+
const ctxPayload = channelRuntime.reply.finalizeInboundContext({
|
|
259
|
+
Body: body,
|
|
260
|
+
RawBody: msg.content || (msg.url ?? ""),
|
|
261
|
+
BodyForAgent: body,
|
|
262
|
+
CommandBody: body,
|
|
263
|
+
...(mediaUrl
|
|
264
|
+
? {
|
|
265
|
+
MediaUrl: mediaUrl,
|
|
266
|
+
MediaUrls: [mediaUrl],
|
|
267
|
+
MediaPath: mediaUrl,
|
|
268
|
+
// 尽量从 type 推断 MIME,否则留空由框架处理
|
|
269
|
+
MediaType: msg.type === "图片" ? "image/jpeg" : msg.type === "视频" ? "video/mp4" : undefined,
|
|
270
|
+
}
|
|
271
|
+
: {}),
|
|
272
|
+
From: fromLabel,
|
|
273
|
+
To: toLabel,
|
|
274
|
+
SessionKey: route.sessionKey,
|
|
275
|
+
AccountId: route.accountId,
|
|
276
|
+
SenderName: msg.senderName || undefined,
|
|
277
|
+
SenderId: msg.senderId,
|
|
278
|
+
ChatType: chatType,
|
|
279
|
+
ConversationLabel: conversationLabel,
|
|
280
|
+
Timestamp: msg.timestamp,
|
|
281
|
+
MessageSid: msg.messageId,
|
|
282
|
+
Provider: "socket-chat",
|
|
283
|
+
Surface: "socket-chat",
|
|
284
|
+
OriginatingChannel: "socket-chat",
|
|
285
|
+
OriginatingTo: toLabel,
|
|
286
|
+
...(msg.isGroup
|
|
287
|
+
? { GroupSubject: msg.groupName ?? peerId }
|
|
288
|
+
: {}),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
// ---- 5. 记录 session 元数据 ----
|
|
293
|
+
const storePath = channelRuntime.session.resolveStorePath(undefined, {
|
|
294
|
+
agentId: route.agentId,
|
|
295
|
+
});
|
|
296
|
+
await channelRuntime.session.recordInboundSession({
|
|
297
|
+
storePath,
|
|
298
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
299
|
+
ctx: ctxPayload,
|
|
300
|
+
onRecordError: (err) => {
|
|
301
|
+
log.error(`[${accountId}] failed updating session meta: ${String(err)}`);
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// 记录 inbound 活动
|
|
306
|
+
channelRuntime.activity.record({
|
|
307
|
+
channel: "socket-chat",
|
|
308
|
+
accountId,
|
|
309
|
+
direction: "inbound",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ---- 6. 派发 AI 回复 ----
|
|
313
|
+
// deliver 负责实际发送:框架当 originatingChannel === currentSurface 时
|
|
314
|
+
// 不走 outbound adapter,直接调用此函数投递回复。
|
|
315
|
+
const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
|
|
316
|
+
const text = payload.text?.trim();
|
|
317
|
+
if (!text) return;
|
|
318
|
+
const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
|
|
319
|
+
const content = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
|
|
320
|
+
await sendReply(replyTarget, content);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
324
|
+
ctx: ctxPayload,
|
|
325
|
+
cfg: ctx.cfg,
|
|
326
|
+
dispatcherOptions: {
|
|
327
|
+
deliver: deliverReply,
|
|
328
|
+
onError: (err, info) => {
|
|
329
|
+
log.error(`[${accountId}] socket-chat ${info.kind} reply failed: ${String(err)}`);
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// 记录 outbound 活动
|
|
335
|
+
channelRuntime.activity.record({
|
|
336
|
+
channel: "socket-chat",
|
|
337
|
+
accountId,
|
|
338
|
+
direction: "outbound",
|
|
339
|
+
});
|
|
340
|
+
} catch (err) {
|
|
341
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
342
|
+
log.error(`[${accountId}] dispatch error for message ${msg.messageId}: ${message}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { MqttClient, IPublishPacket } from "mqtt";
|
|
2
|
+
import mqtt from "mqtt";
|
|
3
|
+
import type { ChannelGatewayContext } from "openclaw/plugin-sdk";
|
|
4
|
+
import { fetchMqttConfigCached, invalidateMqttConfigCache } from "./api.js";
|
|
5
|
+
import { handleInboundMessage } from "./inbound.js";
|
|
6
|
+
import type { ResolvedSocketChatAccount } from "./config.js";
|
|
7
|
+
import type { SocketChatInboundMessage, SocketChatMqttConfig, SocketChatStatusPatch } from "./types.js";
|
|
8
|
+
import { buildTextPayload, sendSocketChatMessage } from "./outbound.js";
|
|
9
|
+
|
|
10
|
+
const MAX_RECONNECT_ATTEMPTS_DEFAULT = 10;
|
|
11
|
+
const RECONNECT_BASE_DELAY_MS_DEFAULT = 2000;
|
|
12
|
+
const RECONNECT_MAX_DELAY_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
type LogSink = NonNullable<ChannelGatewayContext["log"]>;
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// 活跃连接注册表(供 outbound 查找当前 MQTT client)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const activeClients = new Map<string, MqttClient>();
|
|
20
|
+
const activeMqttConfigs = new Map<string, SocketChatMqttConfig>();
|
|
21
|
+
|
|
22
|
+
export function getActiveMqttClient(accountId: string): MqttClient | null {
|
|
23
|
+
return activeClients.get(accountId) ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getActiveMqttConfig(accountId: string): SocketChatMqttConfig | null {
|
|
27
|
+
return activeMqttConfigs.get(accountId) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function setActiveMqttClient(accountId: string, client: MqttClient): void {
|
|
31
|
+
activeClients.set(accountId, client);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setActiveMqttConfig(accountId: string, config: SocketChatMqttConfig): void {
|
|
35
|
+
activeMqttConfigs.set(accountId, config);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clearActiveMqttSession(accountId: string): void {
|
|
39
|
+
const client = activeClients.get(accountId);
|
|
40
|
+
client?.end(true);
|
|
41
|
+
activeClients.delete(accountId);
|
|
42
|
+
activeMqttConfigs.delete(accountId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// 工具函数
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function buildMqttUrl(mqttConfig: SocketChatMqttConfig, useTls: boolean): string {
|
|
50
|
+
const protocol = useTls ? "mqtts" : "mqtt";
|
|
51
|
+
return `${protocol}://${mqttConfig.host}:${mqttConfig.port}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseInboundMessage(raw: Buffer | string): SocketChatInboundMessage | null {
|
|
55
|
+
try {
|
|
56
|
+
const str = Buffer.isBuffer(raw) ? raw.toString("utf8") : raw;
|
|
57
|
+
const obj = JSON.parse(str) as unknown;
|
|
58
|
+
if (!obj || typeof obj !== "object") return null;
|
|
59
|
+
const m = obj as Record<string, unknown>;
|
|
60
|
+
if (
|
|
61
|
+
typeof m.content !== "string" ||
|
|
62
|
+
typeof m.robotId !== "string" ||
|
|
63
|
+
typeof m.senderId !== "string" ||
|
|
64
|
+
typeof m.messageId !== "string"
|
|
65
|
+
) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
content: m.content,
|
|
70
|
+
robotId: m.robotId,
|
|
71
|
+
senderId: m.senderId,
|
|
72
|
+
senderName: typeof m.senderName === "string" ? m.senderName : m.senderId,
|
|
73
|
+
isGroup: m.isGroup === true,
|
|
74
|
+
groupId: typeof m.groupId === "string" ? m.groupId : undefined,
|
|
75
|
+
groupName: typeof m.groupName === "string" ? m.groupName : undefined,
|
|
76
|
+
timestamp: typeof m.timestamp === "number" ? m.timestamp : Date.now(),
|
|
77
|
+
messageId: m.messageId,
|
|
78
|
+
};
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function backoffDelay(attempt: number, baseMs: number): number {
|
|
85
|
+
const delay = Math.min(baseMs * Math.pow(2, attempt), RECONNECT_MAX_DELAY_MS);
|
|
86
|
+
return delay * (0.9 + Math.random() * 0.2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function waitMs(ms: number, signal: AbortSignal): Promise<void> {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const timer = setTimeout(resolve, ms);
|
|
92
|
+
signal.addEventListener("abort", () => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
resolve();
|
|
95
|
+
}, { once: true });
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// 核心 Monitor 循环
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 启动 MQTT 监听循环,支持:
|
|
105
|
+
* - 自动拉取远端 MQTT 配置(带缓存)
|
|
106
|
+
* - 自动重连 + 指数退避
|
|
107
|
+
* - 注册活跃 client 供 outbound 发消息
|
|
108
|
+
* - AbortSignal 优雅停止
|
|
109
|
+
*/
|
|
110
|
+
export async function monitorSocketChatProviderWithRegistry(params: {
|
|
111
|
+
account: ResolvedSocketChatAccount;
|
|
112
|
+
accountId: string;
|
|
113
|
+
ctx: ChannelGatewayContext<ResolvedSocketChatAccount>;
|
|
114
|
+
log: LogSink;
|
|
115
|
+
}): Promise<void> {
|
|
116
|
+
const { account, accountId, ctx, log } = params;
|
|
117
|
+
const { abortSignal } = ctx;
|
|
118
|
+
const maxReconnects = account.config.maxReconnectAttempts ?? MAX_RECONNECT_ATTEMPTS_DEFAULT;
|
|
119
|
+
const reconnectBaseMs = account.config.reconnectBaseDelayMs ?? RECONNECT_BASE_DELAY_MS_DEFAULT;
|
|
120
|
+
const useTls = account.config.useTls ?? false;
|
|
121
|
+
const mqttConfigTtlMs = (account.config.mqttConfigTtlSec ?? 300) * 1000;
|
|
122
|
+
|
|
123
|
+
let reconnectAttempts = 0;
|
|
124
|
+
|
|
125
|
+
const setStatus = (patch: SocketChatStatusPatch): void => {
|
|
126
|
+
ctx.setStatus({ accountId, ...patch } as Parameters<typeof ctx.setStatus>[0]);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
setStatus({ running: true, lastStartAt: Date.now() });
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
while (!abortSignal.aborted) {
|
|
133
|
+
// 1. 拉取 MQTT 配置
|
|
134
|
+
let mqttConfig: SocketChatMqttConfig;
|
|
135
|
+
try {
|
|
136
|
+
mqttConfig = await fetchMqttConfigCached({
|
|
137
|
+
apiBaseUrl: account.apiBaseUrl!,
|
|
138
|
+
apiKey: account.apiKey!,
|
|
139
|
+
ttlMs: mqttConfigTtlMs,
|
|
140
|
+
});
|
|
141
|
+
setActiveMqttConfig(accountId, mqttConfig);
|
|
142
|
+
log.info(`[${accountId}] MQTT config OK, host=${mqttConfig.host}:${mqttConfig.port}`);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
145
|
+
log.error(`[${accountId}] failed to fetch MQTT config: ${message}`);
|
|
146
|
+
setStatus({ lastError: message });
|
|
147
|
+
reconnectAttempts++;
|
|
148
|
+
if (reconnectAttempts > maxReconnects) {
|
|
149
|
+
log.error(`[${accountId}] max reconnect attempts (${maxReconnects}) reached`);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
setStatus({ reconnectAttempts });
|
|
153
|
+
await waitMs(backoffDelay(reconnectAttempts, reconnectBaseMs), abortSignal);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 2. 建立 MQTT 连接
|
|
158
|
+
const mqttUrl = buildMqttUrl(mqttConfig, useTls);
|
|
159
|
+
log.info(`[${accountId}] connecting to ${mqttUrl} (clientId=${mqttConfig.clientId})`);
|
|
160
|
+
|
|
161
|
+
const client = mqtt.connect(mqttUrl, {
|
|
162
|
+
clientId: mqttConfig.clientId,
|
|
163
|
+
username: mqttConfig.username,
|
|
164
|
+
password: mqttConfig.password,
|
|
165
|
+
clean: true,
|
|
166
|
+
reconnectPeriod: 0, // 禁用 mqtt.js 内置重连,由外层循环管理
|
|
167
|
+
connectTimeout: 15_000,
|
|
168
|
+
keepalive: 60,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
setActiveMqttClient(accountId, client);
|
|
172
|
+
|
|
173
|
+
// 等待连接关闭(正常关闭或错误都会 resolve)
|
|
174
|
+
await new Promise<void>((resolve) => {
|
|
175
|
+
const onAbort = (): void => {
|
|
176
|
+
log.info(`[${accountId}] abort signal, disconnecting`);
|
|
177
|
+
client.end(true);
|
|
178
|
+
resolve();
|
|
179
|
+
};
|
|
180
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
181
|
+
|
|
182
|
+
client.on("connect", () => {
|
|
183
|
+
reconnectAttempts = 0;
|
|
184
|
+
setStatus({
|
|
185
|
+
connected: true,
|
|
186
|
+
reconnectAttempts: 0,
|
|
187
|
+
lastConnectedAt: Date.now(),
|
|
188
|
+
lastError: null,
|
|
189
|
+
});
|
|
190
|
+
log.info(`[${accountId}] connected, subscribing to ${mqttConfig.reciveTopic}`);
|
|
191
|
+
|
|
192
|
+
client.subscribe(mqttConfig.reciveTopic, (err: Error | null) => {
|
|
193
|
+
if (err) {
|
|
194
|
+
log.error(`[${accountId}] subscribe error: ${err}, ${err.message}`);
|
|
195
|
+
client.end(true);
|
|
196
|
+
} else {
|
|
197
|
+
log.info(`[${accountId}] subscribed to ${mqttConfig.reciveTopic}`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
client.on("message", (topic: string, rawPayload: Buffer, _packet: IPublishPacket) => {
|
|
203
|
+
if (topic !== mqttConfig.reciveTopic) return;
|
|
204
|
+
setStatus({ lastEventAt: Date.now(), lastInboundAt: Date.now() });
|
|
205
|
+
|
|
206
|
+
const msg = parseInboundMessage(rawPayload);
|
|
207
|
+
if (!msg) {
|
|
208
|
+
log.warn(`[${accountId}] unparseable message on ${topic}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// 跳过机器人自己的回声消息
|
|
212
|
+
if (msg.robotId === mqttConfig.robotId && msg.senderId === mqttConfig.robotId) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
log.info(
|
|
217
|
+
`[${accountId}] inbound msg ${msg.messageId} from ${msg.senderId}` +
|
|
218
|
+
(msg.isGroup ? ` in group ${msg.groupId}` : ""),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
void handleInboundMessage({
|
|
222
|
+
msg,
|
|
223
|
+
accountId,
|
|
224
|
+
ctx,
|
|
225
|
+
log,
|
|
226
|
+
sendReply: async (to: string, text: string) => {
|
|
227
|
+
const payload = buildTextPayload(to, text);
|
|
228
|
+
await sendSocketChatMessage({ mqttClient: client, mqttConfig, payload });
|
|
229
|
+
},
|
|
230
|
+
}).catch((e: unknown) => {
|
|
231
|
+
log.error(
|
|
232
|
+
`[${accountId}] handleInboundMessage error: ${e instanceof Error ? e.message : String(e)}`,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
client.on("error", (err: Error) => {
|
|
238
|
+
log.warn(`[${accountId}] MQTT error: ${err.message}`);
|
|
239
|
+
setStatus({ lastError: err.message });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
client.on("close", () => {
|
|
243
|
+
setStatus({ connected: false, lastDisconnect: new Date().toISOString() });
|
|
244
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
245
|
+
resolve();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// 清理注册表
|
|
250
|
+
activeClients.delete(accountId);
|
|
251
|
+
|
|
252
|
+
if (abortSignal.aborted) break;
|
|
253
|
+
|
|
254
|
+
// 3. 重连退避
|
|
255
|
+
reconnectAttempts++;
|
|
256
|
+
if (reconnectAttempts > maxReconnects) {
|
|
257
|
+
log.error(`[${accountId}] max reconnect attempts (${maxReconnects}) reached, giving up`);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
// 清除 MQTT config 缓存,下次重连时重新拉取(token 可能已过期)
|
|
261
|
+
invalidateMqttConfigCache({ apiBaseUrl: account.apiBaseUrl!, apiKey: account.apiKey! });
|
|
262
|
+
const delay = backoffDelay(reconnectAttempts, reconnectBaseMs);
|
|
263
|
+
log.info(
|
|
264
|
+
`[${accountId}] reconnect ${reconnectAttempts}/${maxReconnects} in ${Math.round(delay)}ms`,
|
|
265
|
+
);
|
|
266
|
+
setStatus({ reconnectAttempts });
|
|
267
|
+
await waitMs(delay, abortSignal);
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
clearActiveMqttSession(accountId);
|
|
271
|
+
setStatus({ running: false, connected: false, lastStopAt: Date.now() });
|
|
272
|
+
log.info(`[${accountId}] monitor stopped`);
|
|
273
|
+
}
|
|
274
|
+
}
|