@qihoo/tuitui-openclaw-channel 1.0.10 → 1.0.11
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 +67 -379
- package/src/command.ts +29 -0
- package/src/confs.ts +84 -37
- package/src/const.ts +2 -0
- package/src/inbound.ts +429 -0
- package/src/{utils.ts → outbound.ts} +163 -49
- 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: `openclaw
|
|
156
|
+
approveHint: `openclaw 配对校验信息,CHANNEL_ID: ${CHANNEL_ID}, AccountId: ${accountId}, code: <code>`,
|
|
175
157
|
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
|
|
176
158
|
};
|
|
177
159
|
},
|
|
@@ -201,7 +183,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
201
183
|
|
|
202
184
|
const chatId = String(to || '').trim();
|
|
203
185
|
// Determine if this is a group message based on 'to' being all digits (group) or not (direct)
|
|
204
|
-
await sendTextMsg(account, chatId,
|
|
186
|
+
await sendTextMsg(account, chatId, guessChatType(chatId), text);
|
|
205
187
|
|
|
206
188
|
return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
|
|
207
189
|
},
|
|
@@ -216,11 +198,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
216
198
|
}
|
|
217
199
|
|
|
218
200
|
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);
|
|
201
|
+
await sendPageMsg(account, chatId, guessChatType(chatId), payload.page);
|
|
224
202
|
|
|
225
203
|
return { channel: CHANNEL_ID, messageId: `tuitui-page-${Date.now()}`, chatId };
|
|
226
204
|
},
|
|
@@ -231,7 +209,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
231
209
|
|
|
232
210
|
const chatId = String(to || '').trim();
|
|
233
211
|
// Determine if this is a group message based on 'to' being all digits (group) or not (direct)
|
|
234
|
-
await sendMediaMsg(account, chatId,
|
|
212
|
+
await sendMediaMsg(account, chatId, guessChatType(chatId), mediaUrl, 'tuitui.send.media');
|
|
235
213
|
|
|
236
214
|
return { channel: CHANNEL_ID, messageId: `tuitui-media-${Date.now()}`, chatId };
|
|
237
215
|
},
|
|
@@ -247,367 +225,77 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
247
225
|
const account = resolveAccount(cfg, accountId);
|
|
248
226
|
|
|
249
227
|
if (!isEnabled(account.enabled)) {
|
|
250
|
-
log?.info?.(`[${CHANNEL_ID}]
|
|
228
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId ${accountId} is disabled, skipping`);
|
|
251
229
|
return _setStatus();
|
|
252
230
|
}
|
|
253
231
|
|
|
254
232
|
if (!isConfigured(account)) {
|
|
255
|
-
log?.warn?.(`[${CHANNEL_ID}]
|
|
233
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId ${accountId} not fully configured (missing appId/appSecret)`);
|
|
256
234
|
return _setStatus();
|
|
257
235
|
}
|
|
258
236
|
|
|
259
|
-
log?.info?.(`[${CHANNEL_ID}]
|
|
237
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Starting TuiTui channel`);
|
|
260
238
|
_setStatus({ running: true });
|
|
261
239
|
let ws: any = null;
|
|
240
|
+
wsNumber++;
|
|
241
|
+
const wsId = `${wsNumber}-${Date.now()}`;
|
|
242
|
+
const wsEvtIds = new Set<string>();
|
|
243
|
+
|
|
262
244
|
const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
|
|
263
245
|
const defSendMsg = (msg: any) => {
|
|
264
|
-
log?.info?.(
|
|
246
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}].Error 环境未就绪,消息发送失败`, msg);
|
|
265
247
|
};
|
|
266
248
|
let _sendMsg = defSendMsg;
|
|
267
249
|
const startWebSocket = () => {
|
|
268
|
-
log?.info?.(`[${CHANNEL_ID}]
|
|
250
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, 准备连接 WebSocket[${wsId}] URL: ${wsUrl}`);
|
|
269
251
|
ws = new WebSocket(wsUrl, { rejectUnauthorized: true });
|
|
270
252
|
ws.on('open', () => {
|
|
271
|
-
log?.info?.(`[${CHANNEL_ID}] WebSocket connect success`);
|
|
253
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] connect success`);
|
|
272
254
|
_setStatus({ running: true, connected: true });
|
|
273
255
|
_sendMsg = (msg) => ws.send(msg);
|
|
274
256
|
});
|
|
275
257
|
|
|
276
258
|
// on receiving messages from tuitui websocket server
|
|
277
259
|
ws.on('message', async (wsData: string) => {
|
|
278
|
-
let json = null;
|
|
260
|
+
let json: any = null;
|
|
279
261
|
try {
|
|
280
262
|
json = JSON.parse(wsData);
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
} catch(err) {
|
|
284
|
-
log?.warn?.(`[${CHANNEL_ID}] WebSocket Message Is Invalid JSON: ${wsData}`);
|
|
263
|
+
} catch {
|
|
264
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Message Is Invalid JSON: ${wsData}`);
|
|
285
265
|
return;
|
|
286
266
|
}
|
|
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
|
-
}
|
|
267
|
+
const ack = json?.event_id;
|
|
268
|
+
if (!ack) {
|
|
269
|
+
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] missing event_id: ${ack}`);
|
|
315
270
|
}
|
|
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;
|
|
271
|
+
if (wsEvtIds.has(ack)) {
|
|
272
|
+
// 主机卡顿 ack 答复不及时等,有可能收到服务端下发重复消息,如果收到则记录日志但不处理
|
|
273
|
+
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Ignore duplicate message event_id: ${ack}`);
|
|
423
274
|
}
|
|
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
|
-
}
|
|
275
|
+
// 收到任意消息则回复一下,权当“收到”
|
|
276
|
+
_sendMsg(JSON.stringify({ ack }));
|
|
277
|
+
// 记录已收到消息的 event_id,避免重复处理同一消息导致的幂等性问题
|
|
278
|
+
wsEvtIds.add(ack);
|
|
279
|
+
// 为了防止 wsEvtIds 无限制增长,这里控制一下长度,超过 1000 则删除最早的一条记录(因为服务端目前最多会囤积1000条消息)
|
|
280
|
+
if (wsEvtIds.size > 1e3) {
|
|
281
|
+
const firsEvtId = wsEvtIds.values().next().value;
|
|
282
|
+
if (firsEvtId) wsEvtIds.delete(firsEvtId);
|
|
500
283
|
}
|
|
501
284
|
|
|
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
|
-
}
|
|
285
|
+
const wsEvent = json?.body?.event;
|
|
286
|
+
if (wsEvent === 'keepalive') return;
|
|
552
287
|
|
|
553
|
-
|
|
554
|
-
if (mediaUrls?.length) msgCtx.MediaUrls = mediaUrls;
|
|
288
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] Received message: ${wsData}`);
|
|
555
289
|
|
|
556
|
-
|
|
557
|
-
|
|
290
|
+
if (!json?.header || !wsEvent || !json?.body?.data) {
|
|
291
|
+
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] invalid message`);
|
|
292
|
+
}
|
|
558
293
|
|
|
559
|
-
// Dispatch via the SDK's buffered block dispatcher
|
|
560
294
|
if (!apiRuntime) {
|
|
561
|
-
log?.error?.(`[${CHANNEL_ID}] TuiTuiRuntime error
|
|
562
|
-
return;
|
|
295
|
+
return log?.error?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] TuiTuiRuntime error`);
|
|
563
296
|
}
|
|
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
|
-
});
|
|
297
|
+
|
|
298
|
+
await handleInboundMessage({ json, account, apiRuntime, log });
|
|
611
299
|
});
|
|
612
300
|
|
|
613
301
|
const onErrOrClose = () => {
|
|
@@ -615,18 +303,18 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
615
303
|
_setStatus({ running: false, connected: false });
|
|
616
304
|
_sendMsg = defSendMsg;
|
|
617
305
|
if (!abortSignal?.aborted) {
|
|
618
|
-
log?.warn?.(`[${CHANNEL_ID}] WebSocket Restart`);
|
|
306
|
+
log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
|
|
619
307
|
setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
|
|
620
308
|
}
|
|
621
309
|
};
|
|
622
310
|
ws.on('close', () => {
|
|
623
|
-
log?.info?.(`[${CHANNEL_ID}] WebSocket closed, ws.readyState
|
|
311
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] closed, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
|
|
624
312
|
onErrOrClose();
|
|
625
313
|
});
|
|
626
314
|
|
|
627
315
|
// on socket errors
|
|
628
316
|
ws.on('error', (err: any) => {
|
|
629
|
-
log?.warn?.(`[${CHANNEL_ID}] WebSocket error: ${err}, ws.readyState
|
|
317
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, WebSocket[${wsId}] error: ${err}, ws.readyState: ${wsReadyStates[ws?.readyState]}`);
|
|
630
318
|
onErrOrClose();
|
|
631
319
|
});
|
|
632
320
|
};
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
2
|
+
export default function registerCommand2Api(api: OpenClawPluginApi) {
|
|
3
|
+
api.registerCommand({
|
|
4
|
+
name: "mystatus",
|
|
5
|
+
description: "Show my plugin status",
|
|
6
|
+
handler: (ctx: any) => {
|
|
7
|
+
console.log('xxxxx', JSON.stringify(ctx.config.channels[ctx.channel]))
|
|
8
|
+
return {
|
|
9
|
+
text: `Status of TuiTui Plugin:
|
|
10
|
+
Channel: ${ctx.channel},
|
|
11
|
+
AccountId: ${ctx.accountId},
|
|
12
|
+
Time: ${new Date().toLocaleString()}`,
|
|
13
|
+
};
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// 带参数的命令
|
|
18
|
+
api.registerCommand({
|
|
19
|
+
name: "setBotName",
|
|
20
|
+
description: "Set bot name",
|
|
21
|
+
acceptsArgs: true,
|
|
22
|
+
requireAuth: true,
|
|
23
|
+
handler: async (ctx: any) => {
|
|
24
|
+
const mode = ctx.args?.trim() || "default";
|
|
25
|
+
///await saveMode(mode);
|
|
26
|
+
return { text: `Mode set to: ${mode}` };
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|