@qihoo/tuitui-openclaw-channel 1.0.10 → 1.0.12
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/index.ts +2 -2
- package/package.json +1 -1
- package/src/channel.ts +78 -386
- package/src/confs.ts +84 -37
- package/src/const.ts +2 -0
- package/src/inbound.ts +449 -0
- package/src/{utils.ts → outbound.ts} +226 -51
- package/src/types.ts +21 -3
package/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
2
2
|
import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
|
|
3
|
-
import { createTuiTuiChannelPlugin } from './src/channel
|
|
4
|
-
import { CHANNEL_ID, CHANNEL_NAME } from './src/
|
|
3
|
+
import { createTuiTuiChannelPlugin } from './src/channel';
|
|
4
|
+
import { CHANNEL_ID, CHANNEL_NAME } from './src/const';
|
|
5
5
|
import { id } from './openclaw.plugin.json';
|
|
6
6
|
|
|
7
7
|
const plugin = {
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -10,19 +10,19 @@ import {
|
|
|
10
10
|
setAccountEnabledInConfigSection,
|
|
11
11
|
deleteAccountFromConfigSection,
|
|
12
12
|
} from 'openclaw/plugin-sdk';
|
|
13
|
-
import
|
|
13
|
+
import { CHANNEL_ID, CHANNEL_NAME } from "./const";
|
|
14
|
+
|
|
14
15
|
import {
|
|
15
|
-
CHANNEL_ID,
|
|
16
|
-
CHANNEL_NAME,
|
|
17
|
-
buildMessageBody,
|
|
18
|
-
tuituiEmojiReaction,
|
|
19
16
|
checkAccount,
|
|
20
17
|
sendTextMsg,
|
|
21
18
|
sendPageMsg,
|
|
22
19
|
sendMediaMsg,
|
|
23
|
-
|
|
20
|
+
guessChatType,
|
|
21
|
+
} from "./outbound";
|
|
22
|
+
|
|
23
|
+
import { handleInboundMessage } from './inbound';
|
|
24
24
|
|
|
25
|
-
import { capabilities, configSchema } from './confs';
|
|
25
|
+
import { capabilities, configSchema, baseFildsDefault } from './confs';
|
|
26
26
|
|
|
27
27
|
const isEnabled = (val: any) => val === undefined || !!val;
|
|
28
28
|
const isConfigured = (account: any)=> !!(account?.appId && account?.appSecret);
|
|
@@ -35,16 +35,17 @@ const resolveAccount = (cfg: any, accountId?: string | null) => {
|
|
|
35
35
|
enabled: isEnabled(acct?.enabled),
|
|
36
36
|
appId: acct?.appId as string | undefined,
|
|
37
37
|
appSecret: acct?.appSecret as string | undefined,
|
|
38
|
-
dmPolicy: acct?.dmPolicy ||
|
|
39
|
-
allowFrom: acct?.allowFrom ||
|
|
38
|
+
dmPolicy: acct?.dmPolicy || baseFildsDefault.dmPolicy,
|
|
39
|
+
allowFrom: acct?.allowFrom || baseFildsDefault.allowFrom,
|
|
40
40
|
// 群组策略与白名单、群组级覆盖
|
|
41
|
-
groupPolicy: (acct?.groupPolicy as string | undefined)
|
|
42
|
-
groupAllowFrom: acct?.groupAllowFrom ||
|
|
43
|
-
|
|
41
|
+
groupPolicy: (acct?.groupPolicy as string | undefined) || baseFildsDefault.groupPolicy,
|
|
42
|
+
groupAllowFrom: acct?.groupAllowFrom || baseFildsDefault.groupAllowFrom,
|
|
43
|
+
requireMention: isEnabled(acct?.requireMention),
|
|
44
|
+
channelContext: acct?.channelContext || baseFildsDefault.channelContext,
|
|
44
45
|
};
|
|
45
46
|
};
|
|
46
|
-
const
|
|
47
|
-
|
|
47
|
+
const wsReadyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const;
|
|
48
|
+
let wsNumber = 0;
|
|
48
49
|
|
|
49
50
|
export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
50
51
|
return {
|
|
@@ -91,48 +92,29 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
91
92
|
...cfg,
|
|
92
93
|
channels: {
|
|
93
94
|
...cfg.channels,
|
|
94
|
-
[CHANNEL_ID]: { ...(cfg
|
|
95
|
+
[CHANNEL_ID]: { ...(cfg.channels?.[CHANNEL_ID] ?? {}), enabled },
|
|
95
96
|
},
|
|
96
97
|
} : setAccountEnabledInConfigSection({
|
|
97
98
|
cfg,
|
|
98
|
-
sectionKey: CHANNEL_ID,
|
|
99
|
-
accountId,
|
|
100
99
|
enabled,
|
|
100
|
+
accountId,
|
|
101
|
+
sectionKey: CHANNEL_ID,
|
|
101
102
|
allowTopLevel: true,
|
|
102
103
|
});
|
|
103
104
|
},
|
|
104
105
|
|
|
105
106
|
deleteAccount: ({ cfg, accountId }: any) => {
|
|
106
|
-
return accountId === DEFAULT_ACCOUNT_ID
|
|
107
|
-
// For default account, we don't delete the entire config
|
|
108
|
-
// Instead, we disable it and clear sensitive fields
|
|
109
|
-
? {
|
|
107
|
+
return accountId === DEFAULT_ACCOUNT_ID ? {
|
|
110
108
|
...cfg,
|
|
111
109
|
channels: {
|
|
112
110
|
...cfg.channels,
|
|
113
111
|
[CHANNEL_ID]: {
|
|
114
|
-
...(cfg
|
|
115
|
-
enabled:
|
|
116
|
-
|
|
117
|
-
appSecret: undefined,
|
|
112
|
+
...(cfg.channels?.[CHANNEL_ID] ?? {}),
|
|
113
|
+
enabled: false, // 默认账户不删除,改为禁用,并重置配置字段
|
|
114
|
+
...baseFildsDefault, // 重置基础配置字段,恢复默认值
|
|
118
115
|
},
|
|
119
116
|
},
|
|
120
|
-
}
|
|
121
|
-
// For named accounts, use the standard delete function
|
|
122
|
-
: deleteAccountFromConfigSection({
|
|
123
|
-
cfg,
|
|
124
|
-
sectionKey: CHANNEL_ID,
|
|
125
|
-
accountId,
|
|
126
|
-
clearBaseFields: [
|
|
127
|
-
'appId',
|
|
128
|
-
'appSecret',
|
|
129
|
-
'dmPolicy',
|
|
130
|
-
'allowFrom',
|
|
131
|
-
'groupPolicy',
|
|
132
|
-
'groupAllowFrom',
|
|
133
|
-
'groups',
|
|
134
|
-
],
|
|
135
|
-
});
|
|
117
|
+
} : deleteAccountFromConfigSection({ cfg, accountId, sectionKey: CHANNEL_ID }); // 多账户状态下的配置信息,按 accountId 删除指定账户; 除非子账户影响根账户字段信息,否则不应该使用 clearBaseFields
|
|
136
118
|
},
|
|
137
119
|
},
|
|
138
120
|
|
|
@@ -160,7 +142,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
160
142
|
let allowFromPath =`channels.${CHANNEL_ID}.`;
|
|
161
143
|
if (accId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${accId}.`;
|
|
162
144
|
|
|
163
|
-
const policy = account.dmPolicy ??
|
|
145
|
+
const policy = account.dmPolicy ?? baseFildsDefault.dmPolicy; // 默认使用 baseFildsDefault 中的 dmPolicy
|
|
164
146
|
// dmPolicy semantics:
|
|
165
147
|
// - open: always allow everyone (["*"]), ignore allowFrom values.
|
|
166
148
|
// - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
|
|
@@ -171,7 +153,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
171
153
|
allowFrom: policy === 'open' ? ['*'] : (account.allowFrom ?? []),
|
|
172
154
|
policyPath: `${allowFromPath}dmPolicy`,
|
|
173
155
|
allowFromPath,
|
|
174
|
-
approveHint:
|
|
156
|
+
approveHint: `当前 ${CHANNEL_ID} openclaw(AccountId: ${accountId})需要配对校验, code: <code>`,
|
|
175
157
|
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
|
|
176
158
|
};
|
|
177
159
|
},
|
|
@@ -192,16 +174,18 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
192
174
|
},
|
|
193
175
|
|
|
194
176
|
outbound: {
|
|
195
|
-
deliveryMode: '
|
|
196
|
-
textChunkLimit:
|
|
177
|
+
deliveryMode: 'direct' as const,
|
|
178
|
+
textChunkLimit: 10000, // API上限制为50k
|
|
197
179
|
|
|
198
|
-
sendText: async ({ cfg, to, text, accountId,
|
|
199
|
-
account =
|
|
200
|
-
checkAccount(account
|
|
180
|
+
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }: any) => {
|
|
181
|
+
const account = resolveAccount(cfg, accountId);
|
|
182
|
+
checkAccount(account);
|
|
201
183
|
|
|
202
184
|
const chatId = String(to || '').trim();
|
|
203
|
-
|
|
204
|
-
|
|
185
|
+
const chatType = guessChatType(chatId);
|
|
186
|
+
console.log(`[${CHANNEL_ID}] AccountId ${accountId} outbound.sendText() ${chatType} to ${chatId} ${text}`);
|
|
187
|
+
|
|
188
|
+
await sendTextMsg(account, chatId, chatType, text);
|
|
205
189
|
|
|
206
190
|
return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
|
|
207
191
|
},
|
|
@@ -216,11 +200,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
216
200
|
}
|
|
217
201
|
|
|
218
202
|
const chatId = String(to || '').trim();
|
|
219
|
-
|
|
220
|
-
// WORKAROUND: OpenClaw core doesn't pass chatType/groupId to sendPayload.
|
|
221
|
-
// If `to` is a numeric string and chatType/groupId are undefined, assume it's a group.
|
|
222
|
-
const isGroup = chatType === 'group' || !!groupId || (isToGroup(chatId) && !chatType);
|
|
223
|
-
await sendPageMsg(account, chatId, isGroup, payload.page);
|
|
203
|
+
await sendPageMsg(account, chatId, guessChatType(chatId), payload.page);
|
|
224
204
|
|
|
225
205
|
return { channel: CHANNEL_ID, messageId: `tuitui-page-${Date.now()}`, chatId };
|
|
226
206
|
},
|
|
@@ -231,7 +211,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
231
211
|
|
|
232
212
|
const chatId = String(to || '').trim();
|
|
233
213
|
// Determine if this is a group message based on 'to' being all digits (group) or not (direct)
|
|
234
|
-
await sendMediaMsg(account, chatId,
|
|
214
|
+
await sendMediaMsg(account, chatId, guessChatType(chatId), mediaUrl, 'tuitui.send.media');
|
|
235
215
|
|
|
236
216
|
return { channel: CHANNEL_ID, messageId: `tuitui-media-${Date.now()}`, chatId };
|
|
237
217
|
},
|
|
@@ -247,367 +227,78 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
247
227
|
const account = resolveAccount(cfg, accountId);
|
|
248
228
|
|
|
249
229
|
if (!isEnabled(account.enabled)) {
|
|
250
|
-
log?.info?.(`[${CHANNEL_ID}]
|
|
230
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId ${accountId} is disabled, skipping`);
|
|
251
231
|
return _setStatus();
|
|
252
232
|
}
|
|
253
233
|
|
|
254
234
|
if (!isConfigured(account)) {
|
|
255
|
-
log?.warn?.(`[${CHANNEL_ID}]
|
|
235
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId ${accountId} not fully configured (missing appId/appSecret)`);
|
|
256
236
|
return _setStatus();
|
|
257
237
|
}
|
|
258
238
|
|
|
259
|
-
log?.info?.(`[${CHANNEL_ID}]
|
|
239
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Starting TuiTui channel`);
|
|
260
240
|
_setStatus({ running: true });
|
|
261
241
|
let ws: any = null;
|
|
242
|
+
wsNumber++;
|
|
243
|
+
const wsId = `${wsNumber}-${Date.now()}`;
|
|
244
|
+
const wsEvtIds = new Set<string>();
|
|
245
|
+
let wsRetryTimerId = 0;
|
|
246
|
+
|
|
262
247
|
const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
|
|
263
248
|
const defSendMsg = (msg: any) => {
|
|
264
|
-
log?.info?.(
|
|
249
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}].Error 环境未就绪,消息发送失败`, msg);
|
|
265
250
|
};
|
|
266
251
|
let _sendMsg = defSendMsg;
|
|
267
252
|
const startWebSocket = () => {
|
|
268
|
-
log?.info?.(`[${CHANNEL_ID}]
|
|
253
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, 准备连接 WebSocket[${wsId}] URL: ${wsUrl}`);
|
|
269
254
|
ws = new WebSocket(wsUrl, { rejectUnauthorized: true });
|
|
270
255
|
ws.on('open', () => {
|
|
271
|
-
log?.info?.(`[${CHANNEL_ID}] WebSocket connect success`);
|
|
256
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] connect success`);
|
|
272
257
|
_setStatus({ running: true, connected: true });
|
|
273
258
|
_sendMsg = (msg) => ws.send(msg);
|
|
274
259
|
});
|
|
275
260
|
|
|
276
261
|
// on receiving messages from tuitui websocket server
|
|
277
262
|
ws.on('message', async (wsData: string) => {
|
|
278
|
-
let json = null;
|
|
263
|
+
let json: any = null;
|
|
279
264
|
try {
|
|
280
265
|
json = JSON.parse(wsData);
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
} catch(err) {
|
|
284
|
-
log?.warn?.(`[${CHANNEL_ID}] WebSocket Message Is Invalid JSON: ${wsData}`);
|
|
266
|
+
} catch {
|
|
267
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Message Is Invalid JSON: ${wsData}`);
|
|
285
268
|
return;
|
|
286
269
|
}
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// 心跳日志太多了,滤掉,异常信息上面 JSON.parse catch 会输出
|
|
291
|
-
if (!isKeepalive) log?.info?.(`[${CHANNEL_ID}] Received TuiTui WebSocket message: ${wsData}`);
|
|
292
|
-
|
|
293
|
-
// 忽略无效信息、心跳信息
|
|
294
|
-
if (!json?.header || !wsEvent || !json?.body?.data|| isKeepalive) return;
|
|
295
|
-
|
|
296
|
-
// Preferred TuiTui signature validation:
|
|
297
|
-
// X-Tuitui-Robot-Checksum = sha1(app_secret + timestamp + nonce + raw_json_body)
|
|
298
|
-
// Also verifies appid in header matches configured appId.
|
|
299
|
-
const { appId, appSecret } = account;
|
|
300
|
-
if (appSecret) {
|
|
301
|
-
const hdAppId = json.header['X-Tuitui-Robot-Appid'];
|
|
302
|
-
const hdTs = json.header['X-Tuitui-Robot-Timestamp'];
|
|
303
|
-
const hdNonce = json.header['X-Tuitui-Robot-Nonce'];
|
|
304
|
-
const hdChecksum = json.header['X-Tuitui-Robot-Checksum'];
|
|
305
|
-
|
|
306
|
-
if (!hdAppId || !hdTs || !hdNonce || !hdChecksum) {
|
|
307
|
-
log?.info?.(`[${CHANNEL_ID}] Missing TuiTui authentication headers`);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
if (appId && hdAppId !== appId) {
|
|
312
|
-
log?.info?.(`[${CHANNEL_ID}] Invalid appId`);
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
270
|
+
const ack = json?.event_id;
|
|
271
|
+
if (!ack) {
|
|
272
|
+
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] missing event_id: ${ack}`);
|
|
315
273
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const msg = json.body as TuiTuiInboundMessage;
|
|
320
|
-
const msgData = msg.data;
|
|
321
|
-
let userAccount: string | undefined = msg.user_account;
|
|
322
|
-
let msgUid: string | undefined = msg.uid;
|
|
323
|
-
let msgUname: string | undefined = msg.user_name;
|
|
324
|
-
let chatType: 'direct' | 'group';
|
|
325
|
-
const chatTypeIsDirect = wsEvent === 'single_chat';
|
|
326
|
-
const chatTypeIsGroup = wsEvent === 'group_chat';
|
|
327
|
-
let text: string;
|
|
328
|
-
let groupId: string | undefined;
|
|
329
|
-
let groupName: string | undefined;
|
|
330
|
-
let mediaUrls: string[] | undefined;
|
|
331
|
-
let replyToId: string | undefined;
|
|
332
|
-
let suppressReply = false; // 是否抑制回复(用于 shouldReply: false 且没有 @ 机器人的情况)
|
|
333
|
-
|
|
334
|
-
// Handle different event types
|
|
335
|
-
if (chatTypeIsDirect) {
|
|
336
|
-
// Single chat message
|
|
337
|
-
chatType = 'direct';
|
|
338
|
-
text = buildMessageBody(msgData);
|
|
339
|
-
|
|
340
|
-
log?.debug?.(`[${CHANNEL_ID}] inbound single_chat user_account=${userAccount} uid=${msgUid} user_name=${msgUname}`);
|
|
341
|
-
|
|
342
|
-
const msgType = msgData.msg_type;
|
|
343
|
-
// Extract media URLs for image/voice/file messages
|
|
344
|
-
if ((msgType === 'image' || msgType === 'mixed') && msgData.images?.length) {
|
|
345
|
-
mediaUrls = msgData.images;
|
|
346
|
-
} else if (msgType === 'voice' && msgData.voice) {
|
|
347
|
-
mediaUrls = [msgData.voice];
|
|
348
|
-
} else if (msgType === 'file' && msgData.file?.url) {
|
|
349
|
-
mediaUrls = [msgData.file.url];
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Handle reference/reply
|
|
353
|
-
if (msgData.ref?.is_me && msgData.ref?.msgid) {
|
|
354
|
-
replyToId = msgData.ref.msgid;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (!userAccount && !msgUid) {
|
|
358
|
-
log?.info?.(`[${CHANNEL_ID}] Missing user_account or uid in single_chat event`);
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
} else if (chatTypeIsGroup) {
|
|
362
|
-
// Group chat message - only process if bot is mentioned
|
|
363
|
-
chatType = 'group';
|
|
364
|
-
groupId = msgData.group_id;
|
|
365
|
-
groupName = msgData.group_name;
|
|
366
|
-
|
|
367
|
-
log?.debug?.(`[${CHANNEL_ID}] inbound group_chat user_account=${userAccount} uid=${msgUid} user_name=${msgUname} group_id=${groupId}`);
|
|
368
|
-
|
|
369
|
-
// Group policy gating and @mention requirements
|
|
370
|
-
const groupPolicy = String(account.groupPolicy ?? "allowlist").toLowerCase();
|
|
371
|
-
const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
|
|
372
|
-
const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
|
|
373
|
-
log?.debug?.(`[${CHANNEL_ID}] groupPolicy=${groupPolicy} groupId=${groupId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`);
|
|
374
|
-
|
|
375
|
-
if (groupPolicy === 'disabled') return log?.info?.('Groups disabled');
|
|
376
|
-
|
|
377
|
-
// 群消息处理策略
|
|
378
|
-
const groupCfg = account.groups?.[String(groupId)];
|
|
379
|
-
|
|
380
|
-
// @机器人触发策略
|
|
381
|
-
const requireMention = typeof groupCfg?.requireMention === 'boolean' ? groupCfg.requireMention : true;
|
|
382
|
-
|
|
383
|
-
// 是否需要回复
|
|
384
|
-
let shouldReply = typeof groupCfg?.shouldReply === 'boolean' ? groupCfg.shouldReply : true;
|
|
385
|
-
|
|
386
|
-
if (!userAccount || !groupId) {
|
|
387
|
-
log?.info?.(`[${CHANNEL_ID}] Missing user_account or group_id in group_chat event`);
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
// 群消息处理逻辑:
|
|
391
|
-
// 1. requireMention: false, shouldReply: true -> 接收所有群消息并自动回复(无论是否 @)
|
|
392
|
-
// 2. requireMention: true, shouldReply: true -> 仅当 @ 机器人时才触发 Agent 并回复,不 @ 的消息直接忽略
|
|
393
|
-
// 3. requireMention: true, shouldReply: false -> 接收所有群消息触发 Agent,但仅当 @ 机器人时才回复
|
|
394
|
-
|
|
395
|
-
if (
|
|
396
|
-
// 消息中 @ 了机器人,必须触发 Agent 并回复(即使 shouldReply: false)
|
|
397
|
-
msgData.at_me ||
|
|
398
|
-
// 消息中没有 @ 机器人,但配置为自动回复(无需 @)
|
|
399
|
-
// 触发 Agent 并回复
|
|
400
|
-
!requireMention ||
|
|
401
|
-
// 消息中没有 @ 机器人,requireMention=true,shouldReply=false
|
|
402
|
-
// 触发 Agent 但不回复(Agent 可以处理消息,但 deliver 不会发送回复)
|
|
403
|
-
!shouldReply
|
|
404
|
-
) {
|
|
405
|
-
text = buildMessageBody(msgData);
|
|
406
|
-
if (!shouldReply) suppressReply = true; // 标记为抑制回复
|
|
407
|
-
// 继续执行后续的 Agent 处理逻辑
|
|
408
|
-
} else {
|
|
409
|
-
// 默认情况:不 @ 机器人,requireMention=true,shouldReply=true
|
|
410
|
-
// 忽略消息
|
|
411
|
-
log?.info?.(`[${CHANNEL_ID}] ignore (not mentioned)`);
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (!normalizedGroupAllowFrom.includes(String(groupId))) {
|
|
416
|
-
await sendTextMsg(account, groupId, true, `当前openclaw群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${groupId}`, 'tuitui.groupPolicy.reply');
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
} else {
|
|
421
|
-
log?.info?.(`[${CHANNEL_ID}] ignore unknown event ${wsEvent}`);
|
|
422
|
-
return;
|
|
274
|
+
if (wsEvtIds.has(ack)) {
|
|
275
|
+
// 主机卡顿 ack 答复不及时等,有可能收到服务端下发重复消息,如果收到则记录日志但不处理
|
|
276
|
+
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Ignore duplicate message event_id: ${ack}`);
|
|
423
277
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const normalizedAllowFrom = arrLowerCaseTrim(configuredAllowFrom);
|
|
433
|
-
// 只使用 userAccount 作为匹配依据,因为用户希望 allowFrom 匹配 user_account
|
|
434
|
-
const senderForPolicy = userAccount ? String(userAccount).toLowerCase().trim() : '';
|
|
435
|
-
log?.debug?.(`[${CHANNEL_ID}] dmPolicy=${dmPolicy} userAccount=${userAccount} msgUid=${msgUid} senderForPolicy=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`);
|
|
436
|
-
|
|
437
|
-
if (chatTypeIsDirect) {
|
|
438
|
-
if (dmPolicy === 'disabled') {
|
|
439
|
-
log?.warn?.(`[${CHANNEL_ID}] DM blocked (disabled) sender=${senderForPolicy}`);
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (dmPolicy !== 'open') {
|
|
444
|
-
// Merge pairing-store entries unless policy is allowlist-only
|
|
445
|
-
let storeAllowFrom: string[] = [];
|
|
446
|
-
if (dmPolicy !== 'allowlist') {
|
|
447
|
-
try {
|
|
448
|
-
const res = await apiRuntime?.channel?.pairing?.readAllowFromStore?.({
|
|
449
|
-
channel: CHANNEL_ID,
|
|
450
|
-
accountId: account.accountId,
|
|
451
|
-
});
|
|
452
|
-
if (Array.isArray(res)) storeAllowFrom = arrLowerCaseTrim(res);
|
|
453
|
-
} catch {}
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// 只检查 userAccount 是否在 allowFrom 或 storeAllowFrom 中
|
|
457
|
-
const allowSet = new Set([...normalizedAllowFrom, ...storeAllowFrom]);
|
|
458
|
-
const isAllowed = userAccount ? allowSet.has(senderForPolicy) : false;
|
|
459
|
-
|
|
460
|
-
if (!isAllowed) {
|
|
461
|
-
if (dmPolicy === 'pairing') {
|
|
462
|
-
try {
|
|
463
|
-
log?.debug?.(`[${CHANNEL_ID}] pairing flow: checking if pairing request exists for sender=${senderForPolicy}`);
|
|
464
|
-
const req = await apiRuntime?.channel?.pairing?.upsertPairingRequest?.({
|
|
465
|
-
channel: CHANNEL_ID,
|
|
466
|
-
accountId: account.accountId,
|
|
467
|
-
id: senderForPolicy,
|
|
468
|
-
});
|
|
469
|
-
log?.debug?.(
|
|
470
|
-
`[${CHANNEL_ID}] pairing flow: upsertPairingRequest result=${JSON.stringify(req)}`,
|
|
471
|
-
);
|
|
472
|
-
/***
|
|
473
|
-
* 把 req?.created 注释掉。不然一旦发消息失败,就进入死局。
|
|
474
|
-
* 删除这个标志,只要没配对,会每次和机器人聊,都会返回配对信息。
|
|
475
|
-
*/
|
|
476
|
-
if (/*req?.created && */ account.appId && account.appSecret) {
|
|
477
|
-
const replyText =
|
|
478
|
-
apiRuntime?.channel?.pairing?.buildPairingReply?.({
|
|
479
|
-
channel: CHANNEL_ID,
|
|
480
|
-
code: req.code,
|
|
481
|
-
}) ?? '需要进行配对。请让机器人所有者进行批准。';
|
|
482
|
-
|
|
483
|
-
// tousers 使用 userAccount(推推用户账号)
|
|
484
|
-
const toUname = String(userAccount || '').trim();
|
|
485
|
-
await sendTextMsg(account, toUname, false, replyText, 'tuitui.pairing.reply');
|
|
486
|
-
}
|
|
487
|
-
} catch (err) {
|
|
488
|
-
log?.warn?.(`[${CHANNEL_ID}] pairing flow failed for ${senderForPolicy}: ${String(err)}` );
|
|
489
|
-
}
|
|
490
|
-
// Drop unauthorized DM after pairing challenge
|
|
491
|
-
log?.info?.(`[${CHANNEL_ID}] pairing required`);
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// dmPolicy=allowlist and sender not allowed
|
|
496
|
-
log?.warn?.(`[${CHANNEL_ID}] Blocked unauthorized sender (allowlist): sender=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`);
|
|
497
|
-
return;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
278
|
+
// 收到任意消息则回复一下,权当“收到”
|
|
279
|
+
_sendMsg(JSON.stringify({ ack }));
|
|
280
|
+
// 记录已收到消息的 event_id,避免重复处理同一消息导致的幂等性问题
|
|
281
|
+
wsEvtIds.add(ack);
|
|
282
|
+
// 为了防止 wsEvtIds 无限制增长,这里控制一下长度,超过 1000 则删除最早的一条记录(因为服务端目前最多会囤积1000条消息)
|
|
283
|
+
if (wsEvtIds.size > 1e3) {
|
|
284
|
+
const firsEvtId = wsEvtIds.values().next().value;
|
|
285
|
+
if (firsEvtId) wsEvtIds.delete(firsEvtId);
|
|
500
286
|
}
|
|
501
287
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
// Build MsgContext
|
|
506
|
-
// 优先使用 userAccount,如果为空则降级使用 msgUid
|
|
507
|
-
const senderId = userAccount || msgUid || 'unknown';
|
|
508
|
-
// 确保 accountId 有有效值,优先使用 account.accountId,其次使用 ctx 中的 accountId,最后使用 DEFAULT_ACCOUNT_ID
|
|
509
|
-
|
|
510
|
-
const effectiveAccountId = String(account.accountId || accountId || DEFAULT_ACCOUNT_ID || 'default');
|
|
511
|
-
const chatId = chatTypeIsGroup ? String(groupId) : String(senderId);
|
|
512
|
-
|
|
513
|
-
// 关于 sessionKey 格式的解释
|
|
514
|
-
// https://docs.openclaw.ai/channels/channel-routing
|
|
515
|
-
// 但 sessionKey 一般不要自己拼字符串,需要用系统api识别(根据 bindings)
|
|
516
|
-
const route = apiRuntime.channel.routing.resolveAgentRoute({
|
|
517
|
-
cfg: await apiRuntime.config.loadConfig(),
|
|
518
|
-
channel: CHANNEL_ID,
|
|
519
|
-
accountId: account.accountId,
|
|
520
|
-
peer: {
|
|
521
|
-
kind: chatTypeIsGroup ? 'group' : 'direct',
|
|
522
|
-
id: chatId,
|
|
523
|
-
},
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
const sessionKey = route.sessionKey;
|
|
527
|
-
console.log(`[${CHANNEL_ID}] chatType: ${chatType}, chatId ${chatId}, senderId: ${senderId}, accountId: ${accountId}, DEFAULT_ACCOUNT_ID: ${DEFAULT_ACCOUNT_ID}, effectiveAccountId: ${effectiveAccountId}`);
|
|
528
|
-
console.log(`[${CHANNEL_ID}] dispatching to agent session=${sessionKey}`);
|
|
529
|
-
|
|
530
|
-
const msgCtx: any = {
|
|
531
|
-
Body: text || ' ',
|
|
532
|
-
From: String(senderId),
|
|
533
|
-
To: CHANNEL_ID,
|
|
534
|
-
SessionId: String(sessionKey).replace(/\//g, '_'),
|
|
535
|
-
SessionKey: String(sessionKey).replace(/\//g, '_'),
|
|
536
|
-
AccountId: effectiveAccountId,
|
|
537
|
-
OriginatingChannel: CHANNEL_ID,
|
|
538
|
-
OriginatingTo: chatId,
|
|
539
|
-
ChatType: chatType,
|
|
540
|
-
Surface: CHANNEL_ID,
|
|
541
|
-
Provider: CHANNEL_ID,
|
|
542
|
-
SenderName: msgUname || String(senderId),
|
|
543
|
-
MsgUname: msgUname,
|
|
544
|
-
UserAccount: userAccount,
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
// Add group-specific fields
|
|
548
|
-
if (chatTypeIsGroup && groupId) {
|
|
549
|
-
msgCtx.GroupSubject = groupName;
|
|
550
|
-
msgCtx.GroupId = groupId;
|
|
551
|
-
}
|
|
288
|
+
const wsEvent = json?.body?.event;
|
|
289
|
+
if (wsEvent === 'keepalive') return;
|
|
552
290
|
|
|
553
|
-
|
|
554
|
-
if (mediaUrls?.length) msgCtx.MediaUrls = mediaUrls;
|
|
291
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Received message: ${wsData}`);
|
|
555
292
|
|
|
556
|
-
|
|
557
|
-
|
|
293
|
+
if (!json?.header || !wsEvent || !json?.body?.data) {
|
|
294
|
+
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
|
|
295
|
+
}
|
|
558
296
|
|
|
559
|
-
// Dispatch via the SDK's buffered block dispatcher
|
|
560
297
|
if (!apiRuntime) {
|
|
561
|
-
log?.error?.(`[${CHANNEL_ID}] TuiTuiRuntime error
|
|
562
|
-
return;
|
|
298
|
+
return log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] TuiTuiRuntime error`);
|
|
563
299
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
cfg: await apiRuntime.config.loadConfig(),
|
|
567
|
-
dispatcherOptions: {
|
|
568
|
-
deliver: async (payload: TuiTuiOutboundDeliverOptions) => {
|
|
569
|
-
|
|
570
|
-
// 如果设置了 suppressReply 标志,则不发送回复
|
|
571
|
-
if (suppressReply) {
|
|
572
|
-
log?.debug?.(`[${CHANNEL_ID}] Reply suppressed by configuration (shouldReply: false)`);
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const chatTarget = chatTypeIsGroup ? groupId : userAccount;
|
|
577
|
-
// Handle custom messages (e.g., page messages)
|
|
578
|
-
if (payload.custom) {
|
|
579
|
-
const { msgtype, page, tousers, togroups } = payload.custom;
|
|
580
|
-
// Handle page message type
|
|
581
|
-
if (msgtype === 'page' && page) {
|
|
582
|
-
await sendPageMsg(account, chatTarget, chatTypeIsGroup, page, 'tuitui.deliver.page', tousers, togroups);
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
log?.warn?.(`[${CHANNEL_ID}] Unsupported custom message type: ${msgtype}`);
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
if (payload.mediaUrl || payload.mediaUrls?.length) {
|
|
589
|
-
// Handle media messages
|
|
590
|
-
const mediaUrl = payload.mediaUrl || payload.mediaUrls?.[0];
|
|
591
|
-
if (mediaUrl) {
|
|
592
|
-
const at = chatTypeIsGroup && userAccount ? [userAccount] : [];
|
|
593
|
-
await sendMediaMsg(account, chatTarget, chatTypeIsGroup, mediaUrl, 'tuitui.deliver.media', at);
|
|
594
|
-
}
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
// Handle text messages
|
|
598
|
-
const replyText = payload?.text || payload?.body;
|
|
599
|
-
if (replyText) {
|
|
600
|
-
// at 使用 userAccount(群聊 @ 用户)
|
|
601
|
-
const at = chatTypeIsGroup && userAccount ? [userAccount] : [];
|
|
602
|
-
// 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
|
|
603
|
-
await sendTextMsg(account, chatTarget, chatTypeIsGroup, replyText, 'tuitui.deliver.text', at);
|
|
604
|
-
}
|
|
605
|
-
},
|
|
606
|
-
onReplyStart: () => {
|
|
607
|
-
log?.info?.(`[${CHANNEL_ID}] Agent reply started for ${userAccount ?? msgUid}`);
|
|
608
|
-
},
|
|
609
|
-
},
|
|
610
|
-
});
|
|
300
|
+
|
|
301
|
+
await handleInboundMessage({ json, account, apiRuntime, log });
|
|
611
302
|
});
|
|
612
303
|
|
|
613
304
|
const onErrOrClose = () => {
|
|
@@ -615,18 +306,19 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
615
306
|
_setStatus({ running: false, connected: false });
|
|
616
307
|
_sendMsg = defSendMsg;
|
|
617
308
|
if (!abortSignal?.aborted) {
|
|
618
|
-
log?.warn?.(`[${CHANNEL_ID}] WebSocket Restart`);
|
|
619
|
-
|
|
309
|
+
log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
|
|
310
|
+
if (wsRetryTimerId) clearTimeout(wsRetryTimerId);
|
|
311
|
+
wsRetryTimerId = setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
|
|
620
312
|
}
|
|
621
313
|
};
|
|
622
314
|
ws.on('close', () => {
|
|
623
|
-
log?.info?.(`[${CHANNEL_ID}] WebSocket closed, ws.readyState
|
|
315
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] closed, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
|
|
624
316
|
onErrOrClose();
|
|
625
317
|
});
|
|
626
318
|
|
|
627
319
|
// on socket errors
|
|
628
320
|
ws.on('error', (err: any) => {
|
|
629
|
-
log?.warn?.(`[${CHANNEL_ID}] WebSocket error: ${err}, ws.readyState
|
|
321
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] error: ${err}, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
|
|
630
322
|
onErrOrClose();
|
|
631
323
|
});
|
|
632
324
|
};
|