@qihoo/tuitui-openclaw-channel 1.0.11 → 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/package.json +1 -1
- package/src/channel.ts +13 -9
- package/src/inbound.ts +135 -115
- package/src/outbound.ts +67 -6
- package/src/command.ts +0 -29
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -153,7 +153,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
153
153
|
allowFrom: policy === 'open' ? ['*'] : (account.allowFrom ?? []),
|
|
154
154
|
policyPath: `${allowFromPath}dmPolicy`,
|
|
155
155
|
allowFromPath,
|
|
156
|
-
approveHint:
|
|
156
|
+
approveHint: `当前 ${CHANNEL_ID} openclaw(AccountId: ${accountId})需要配对校验, code: <code>`,
|
|
157
157
|
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
|
|
158
158
|
};
|
|
159
159
|
},
|
|
@@ -174,16 +174,18 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
174
174
|
},
|
|
175
175
|
|
|
176
176
|
outbound: {
|
|
177
|
-
deliveryMode: '
|
|
178
|
-
textChunkLimit:
|
|
177
|
+
deliveryMode: 'direct' as const,
|
|
178
|
+
textChunkLimit: 10000, // API上限制为50k
|
|
179
179
|
|
|
180
|
-
sendText: async ({ cfg, to, text, accountId,
|
|
181
|
-
account =
|
|
182
|
-
checkAccount(account
|
|
180
|
+
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }: any) => {
|
|
181
|
+
const account = resolveAccount(cfg, accountId);
|
|
182
|
+
checkAccount(account);
|
|
183
183
|
|
|
184
184
|
const chatId = String(to || '').trim();
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
187
189
|
|
|
188
190
|
return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
|
|
189
191
|
},
|
|
@@ -240,6 +242,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
240
242
|
wsNumber++;
|
|
241
243
|
const wsId = `${wsNumber}-${Date.now()}`;
|
|
242
244
|
const wsEvtIds = new Set<string>();
|
|
245
|
+
let wsRetryTimerId = 0;
|
|
243
246
|
|
|
244
247
|
const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
|
|
245
248
|
const defSendMsg = (msg: any) => {
|
|
@@ -304,7 +307,8 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
304
307
|
_sendMsg = defSendMsg;
|
|
305
308
|
if (!abortSignal?.aborted) {
|
|
306
309
|
log?.warn?.(`[${CHANNEL_ID}] WebSocket[${wsId}] Restart`);
|
|
307
|
-
|
|
310
|
+
if (wsRetryTimerId) clearTimeout(wsRetryTimerId);
|
|
311
|
+
wsRetryTimerId = setTimeout(startWebSocket, 10e3); // 10秒后尝试重启
|
|
308
312
|
}
|
|
309
313
|
};
|
|
310
314
|
ws.on('close', () => {
|
package/src/inbound.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* await handleInboundMessage(json, { account, accountId, apiRuntime, log });
|
|
11
11
|
* });
|
|
12
12
|
*/
|
|
13
|
-
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk';
|
|
14
13
|
import type { TuiTuiInboundMessage, TuiTuiOutboundDeliverOptions } from './types';
|
|
15
14
|
import { CHANNEL_ID } from './const';
|
|
16
15
|
|
|
@@ -60,6 +59,47 @@ export interface InboundHandlerOptions {
|
|
|
60
59
|
log: any;
|
|
61
60
|
}
|
|
62
61
|
|
|
62
|
+
function getSessionKey(
|
|
63
|
+
cfg: any,
|
|
64
|
+
payload: ChatPayload,
|
|
65
|
+
account: InboundHandlerOptions['account'],
|
|
66
|
+
apiRuntime: InboundHandlerOptions['apiRuntime']
|
|
67
|
+
): string | undefined {
|
|
68
|
+
const { chatId, chatType } = payload;
|
|
69
|
+
if (!chatId) return undefined;
|
|
70
|
+
const { accountId, channelContext } = account;
|
|
71
|
+
// 你自己只需要拼接与定义 peer.id
|
|
72
|
+
// 关于 sessionKey 格式的解释: https://docs.openclaw.ai/channels/channel-routing#sessionkey-%E5%8F%82%E8%80%83%E6%A0%BC%E5%BC%8F
|
|
73
|
+
let id = chatId;
|
|
74
|
+
if(chatType == CHAT_TYPE_CHANNEL) {
|
|
75
|
+
const { channel_id, parent_id } = teamsParseChatId(chatId);
|
|
76
|
+
id = `${channel_id}`;
|
|
77
|
+
if(channelContext == 'thread' && parent_id) {
|
|
78
|
+
id += `:thread:${parent_id}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// sessionKey 不能自己拼字符串,需要用系统api识别,他会根据 bindings 路由规则处理
|
|
83
|
+
const { sessionKey } = apiRuntime.channel.routing.resolveAgentRoute({
|
|
84
|
+
cfg,
|
|
85
|
+
accountId,
|
|
86
|
+
channel: CHANNEL_ID,
|
|
87
|
+
peer: { kind: chatType, id: id },
|
|
88
|
+
});
|
|
89
|
+
return sessionKey;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getMediaUrls({ msg_type, images, voice, file }: any): string[] | undefined {
|
|
93
|
+
if (msg_type === 'image' || msg_type === 'mixed') {
|
|
94
|
+
if (images?.length) return images;
|
|
95
|
+
} else if (msg_type === 'voice') {
|
|
96
|
+
if (voice) return [voice];
|
|
97
|
+
} else if (msg_type === 'file') {
|
|
98
|
+
if (file?.url) return [file.url];
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
63
103
|
/**
|
|
64
104
|
* 处理长链接消息
|
|
65
105
|
* account: 长连接初始化时,对应的Account配置信息
|
|
@@ -68,31 +108,27 @@ export interface InboundHandlerOptions {
|
|
|
68
108
|
export async function handleInboundMessage({ json, account, apiRuntime, log }: InboundHandlerOptions) {
|
|
69
109
|
// Signature / AppId validation
|
|
70
110
|
const { accountId, appId, appSecret } = account;
|
|
71
|
-
if (appSecret) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
log?.info?.(`[${CHANNEL_ID}] Invalid appId`);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
111
|
+
if (appSecret && appId && json.header['X-Tuitui-Robot-Appid'] !== appId) {
|
|
112
|
+
log?.info?.(`[${CHANNEL_ID}] Invalid appId`);
|
|
113
|
+
return;
|
|
77
114
|
}
|
|
78
115
|
|
|
79
116
|
// Decode message
|
|
80
|
-
const
|
|
81
|
-
const msg = json.body as TuiTuiInboundMessage;
|
|
117
|
+
const msg = json.body as TuiTuiInboundMessage;
|
|
82
118
|
const msgData = msg.data;
|
|
83
|
-
|
|
119
|
+
const { ref } = msgData;
|
|
84
120
|
const payload: ChatPayload = {
|
|
85
121
|
chatType: CHAT_TYPE_DIRECT,
|
|
86
122
|
chatId: undefined,
|
|
87
123
|
text: undefined,
|
|
88
124
|
groupName: undefined,
|
|
89
|
-
mediaUrls:
|
|
90
|
-
replyToId: undefined,
|
|
125
|
+
mediaUrls: getMediaUrls(msgData),
|
|
126
|
+
replyToId: ref?.is_me && ref?.msgid ? ref.msgid : undefined,
|
|
91
127
|
tuituiAccount: msg.user_account || "",
|
|
92
128
|
tuituiUid: msg.uid || "",
|
|
93
|
-
tuituiUserName:msg.user_name || "",
|
|
129
|
+
tuituiUserName: msg.user_name || "",
|
|
94
130
|
};
|
|
95
|
-
|
|
131
|
+
const wsEvent = json.body.event;
|
|
96
132
|
// Event-type branching
|
|
97
133
|
if (wsEvent === 'single_chat') {
|
|
98
134
|
const valid = await parseSingleChat(payload, msgData, account, apiRuntime, log);
|
|
@@ -117,44 +153,20 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
|
|
|
117
153
|
|
|
118
154
|
// 路由判断
|
|
119
155
|
const routeSenderFrom = payload.tuituiAccount || payload.tuituiUid || 'unknown';
|
|
120
|
-
const routeAccountId = String(accountId || DEFAULT_ACCOUNT_ID || 'default');
|
|
121
156
|
|
|
122
157
|
const cfg = await apiRuntime.config.loadConfig();
|
|
123
|
-
|
|
124
|
-
// 关于 sessionKey 格式的解释
|
|
125
|
-
// https://docs.openclaw.ai/channels/channel-routing
|
|
126
|
-
var peer_id = payload.chatId
|
|
127
|
-
if(payload.chatType == CHAT_TYPE_CHANNEL) {
|
|
128
|
-
const target = teamsParseChatId(payload.chatId)
|
|
129
|
-
if(account?.channelContext == "thread" && target.parent_id) {
|
|
130
|
-
peer_id = `${target.channel_id}:thread:${target.parent_id}`;
|
|
131
|
-
} else {
|
|
132
|
-
peer_id = `${target.channel_id}`;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
158
|
+
const sessionKey = getSessionKey(cfg, payload, account, apiRuntime);
|
|
135
159
|
|
|
136
|
-
|
|
137
|
-
// sessionKey 不能自己拼字符串,需要用系统api识别,他会根据 bindings 路由规则处理
|
|
138
|
-
const { sessionKey } = apiRuntime.channel.routing.resolveAgentRoute({
|
|
139
|
-
cfg,
|
|
140
|
-
channel: CHANNEL_ID,
|
|
141
|
-
accountId,
|
|
142
|
-
peer: {
|
|
143
|
-
kind: payload.chatType,
|
|
144
|
-
id: peer_id,
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, chatType: ${payload.chatType}, chatId ${payload.chatId}, routeSenderFrom: ${routeSenderFrom}, DEFAULT_ACCOUNT_ID: ${DEFAULT_ACCOUNT_ID}, routeAccountId: ${routeAccountId}`);
|
|
160
|
+
console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, chatType: ${payload.chatType}, chatId ${payload.chatId}, routeSenderFrom: ${routeSenderFrom}`);
|
|
149
161
|
console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, dispatching to agent session=${sessionKey}`);
|
|
150
162
|
|
|
151
|
-
const
|
|
163
|
+
const ctx: any = {
|
|
152
164
|
Body: payload.text! || ' ',
|
|
153
165
|
From: String(routeSenderFrom),
|
|
154
166
|
To: CHANNEL_ID,
|
|
155
167
|
SessionId: String(sessionKey).replace(/\//g, '_'),
|
|
156
168
|
SessionKey: String(sessionKey).replace(/\//g, '_'),
|
|
157
|
-
AccountId:
|
|
169
|
+
AccountId: accountId,
|
|
158
170
|
OriginatingChannel: CHANNEL_ID,
|
|
159
171
|
OriginatingTo: payload.chatId,
|
|
160
172
|
ChatType: payload.chatType!,
|
|
@@ -165,19 +177,18 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
|
|
|
165
177
|
UserAccount: payload.tuituiAccount,
|
|
166
178
|
CommandAuthorized: true, // 允许 /new 等内置命令
|
|
167
179
|
};
|
|
168
|
-
|
|
169
180
|
if (payload.chatType == CHAT_TYPE_GROUP && payload.chatId) {
|
|
170
|
-
|
|
171
|
-
|
|
181
|
+
ctx.GroupSubject = payload.groupName;
|
|
182
|
+
ctx.GroupId = payload.chatId;
|
|
172
183
|
}
|
|
173
|
-
if (payload.mediaUrls?.length)
|
|
174
|
-
if (payload.replyToId)
|
|
184
|
+
if (payload.mediaUrls?.length) ctx.MediaUrls = payload.mediaUrls;
|
|
185
|
+
if (payload.replyToId) ctx.ReplyToId = payload.replyToId;
|
|
175
186
|
|
|
176
187
|
console.log(`[${CHANNEL_ID}] AccountId: ${accountId}, handleInboundMessage payload`, payload);
|
|
177
188
|
|
|
178
189
|
|
|
179
190
|
await apiRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
180
|
-
ctx
|
|
191
|
+
ctx,
|
|
181
192
|
cfg,
|
|
182
193
|
dispatcherOptions: {
|
|
183
194
|
deliver: async (outbound: TuiTuiOutboundDeliverOptions) => {
|
|
@@ -216,6 +227,40 @@ export async function handleInboundMessage({ json, account, apiRuntime, log }: I
|
|
|
216
227
|
});
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
/*** 配对节流映射 - 用于防止短时间内大量重复配对请求消息发出
|
|
231
|
+
* key: `${accountId}-${chatId}`
|
|
232
|
+
* val: timestamp */
|
|
233
|
+
const pairingThrottleMap = new Map<string, number>();
|
|
234
|
+
// 暂时控制为 2 分钟内同一个用户只能触发一次配对请求
|
|
235
|
+
const needPairingThrottle = (accountId: string | undefined, chatId: string, throttleMs = 2 * 60 * 1000): boolean => {
|
|
236
|
+
const key = `${accountId}-${chatId}`;
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const lastTime = pairingThrottleMap.get(key) || 0;
|
|
239
|
+
if (now - lastTime < throttleMs) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
pairingThrottleMap.set(key, now);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function sendSingleChatPairingMsg(account: any, payload: any, log: any, apiRuntime: any) {
|
|
247
|
+
const { accountId } = account;
|
|
248
|
+
const { chatId, chatType } = payload;
|
|
249
|
+
if (needPairingThrottle(accountId, chatId)) {
|
|
250
|
+
return log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing throttled for sender=${chatId}`);
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
log?.debug?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing flow: checking if pairing request exists for sender=${chatId}`);
|
|
254
|
+
const req = await apiRuntime.channel.pairing.upsertPairingRequest({ channel: CHANNEL_ID, accountId, id: chatId });
|
|
255
|
+
log?.debug?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing flow: upsertPairingRequest result=${JSON.stringify(req)}`);
|
|
256
|
+
// 只要没配对,会每次和机器人聊,都会返回配对信息。
|
|
257
|
+
const replyText = apiRuntime.channel.pairing.buildPairingReply({ channel: CHANNEL_ID, code: req.code });
|
|
258
|
+
await sendTextMsg(account, chatId, chatType, replyText, 'tuitui.pairing.reply');
|
|
259
|
+
} catch (err) {
|
|
260
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing failed for ${chatId}: ${String(err)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
219
264
|
/**
|
|
220
265
|
* 处理单聊(single_chat)分支,直接修改 payload。
|
|
221
266
|
* 返回 false 表示消息不合法,外层应提前 return;返回 true 表示校验通过,继续执行。
|
|
@@ -227,46 +272,32 @@ async function parseSingleChat(
|
|
|
227
272
|
apiRuntime: any,
|
|
228
273
|
log: any,
|
|
229
274
|
): Promise<boolean> {
|
|
230
|
-
const { accountId } = account;
|
|
275
|
+
const { accountId, dmPolicy } = account;
|
|
276
|
+
const chatId = payload.tuituiAccount ? String(payload.tuituiAccount || '').toLowerCase().trim() : '';
|
|
277
|
+
if (dmPolicy === 'disabled') { //['pairing', 'allowlist', 'open', 'disabled'],
|
|
278
|
+
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${chatId}`);
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
231
282
|
payload.chatType = CHAT_TYPE_DIRECT;
|
|
232
|
-
payload.chatId
|
|
283
|
+
payload.chatId = chatId;
|
|
233
284
|
payload.text = buildMessageBody(msgData);
|
|
234
|
-
payload.msgId
|
|
285
|
+
payload.msgId = msgData.msgid;
|
|
235
286
|
log?.debug?.(
|
|
236
|
-
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat user_account=${
|
|
287
|
+
`[${CHANNEL_ID}] AccountId: ${accountId}, inbound single_chat user_account=${chatId} uid=${payload.tuituiUid} user_name=${payload.tuituiUserName}`,
|
|
237
288
|
);
|
|
238
289
|
|
|
239
|
-
|
|
240
|
-
if ((msgType === 'image' || msgType === 'mixed') && msgData.images?.length) {
|
|
241
|
-
payload.mediaUrls = msgData.images;
|
|
242
|
-
} else if (msgType === 'voice' && msgData.voice) {
|
|
243
|
-
payload.mediaUrls = [msgData.voice];
|
|
244
|
-
} else if (msgType === 'file' && msgData.file?.url) {
|
|
245
|
-
payload.mediaUrls = [msgData.file.url];
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (msgData.ref?.is_me && msgData.ref?.msgid) {
|
|
249
|
-
payload.replyToId = msgData.ref.msgid;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (!payload.tuituiAccount && !payload.tuituiUid) {
|
|
290
|
+
if (!chatId && !payload.tuituiUid) {
|
|
253
291
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing user_account or uid in single_chat event`);
|
|
254
292
|
return false;
|
|
255
293
|
}
|
|
256
294
|
|
|
257
|
-
const dmPolicy = String(account.dmPolicy ?? 'pairing').toLowerCase();
|
|
258
295
|
const configuredAllowFrom = Array.isArray(account.allowFrom) ? account.allowFrom : [];
|
|
259
296
|
const normalizedAllowFrom = arrLowerCaseTrim(configuredAllowFrom);
|
|
260
|
-
const senderForPolicy = payload.tuituiAccount ? String(payload.tuituiAccount).toLowerCase().trim() : '';
|
|
261
297
|
log?.debug?.(
|
|
262
|
-
`[${CHANNEL_ID}] AccountId: ${accountId}, dmPolicy=${dmPolicy} userAccount=${
|
|
298
|
+
`[${CHANNEL_ID}] AccountId: ${accountId}, dmPolicy=${dmPolicy} userAccount=${chatId} tuituiUid=${payload.tuituiUid} chatId=${chatId} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
|
|
263
299
|
);
|
|
264
300
|
|
|
265
|
-
if (dmPolicy === 'disabled') {
|
|
266
|
-
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, DM disabled sender=${senderForPolicy}`);
|
|
267
|
-
return false;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
301
|
if (dmPolicy === 'open') {
|
|
271
302
|
return true;
|
|
272
303
|
}
|
|
@@ -278,36 +309,16 @@ async function parseSingleChat(
|
|
|
278
309
|
} catch {}
|
|
279
310
|
|
|
280
311
|
const allowSet = new Set([...normalizedAllowFrom, ...storeAllowFrom]);
|
|
281
|
-
const isAllowed =
|
|
312
|
+
const isAllowed = chatId ? allowSet.has(chatId) : false;
|
|
282
313
|
if(isAllowed) return true;
|
|
283
314
|
|
|
284
315
|
if (dmPolicy === 'pairing') {
|
|
285
|
-
|
|
286
|
-
log?.debug?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing flow: checking if pairing request exists for sender=${senderForPolicy}`);
|
|
287
|
-
const req = await apiRuntime?.channel?.pairing?.upsertPairingRequest?.({
|
|
288
|
-
channel: CHANNEL_ID,
|
|
289
|
-
accountId,
|
|
290
|
-
id: senderForPolicy,
|
|
291
|
-
});
|
|
292
|
-
log?.debug?.(
|
|
293
|
-
`[${CHANNEL_ID}] AccountId: ${accountId}, pairing flow: upsertPairingRequest result=${JSON.stringify(req)}`,
|
|
294
|
-
);
|
|
295
|
-
// 只要没配对,会每次和机器人聊,都会返回配对信息。
|
|
296
|
-
const replyText =
|
|
297
|
-
apiRuntime?.channel?.pairing?.buildPairingReply?.({
|
|
298
|
-
channel: CHANNEL_ID,
|
|
299
|
-
code: req.code,
|
|
300
|
-
}) ?? '需要进行配对。请让机器人所有者进行批准。';
|
|
301
|
-
|
|
302
|
-
await sendTextMsg(account, payload.tuituiAccount, payload.chatType, replyText, 'tuitui.pairing.reply');
|
|
303
|
-
} catch (err) {
|
|
304
|
-
log?.warn?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing failed for ${senderForPolicy}: ${String(err)}`);
|
|
305
|
-
}
|
|
316
|
+
sendSingleChatPairingMsg(account, payload, log, apiRuntime);
|
|
306
317
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, pairing required`);
|
|
307
318
|
return false;
|
|
308
319
|
} else { // allowlist
|
|
309
320
|
log?.warn?.(
|
|
310
|
-
`[${CHANNEL_ID}] AccountId: ${accountId}, Blocked unauthorized sender (allowlist): sender=${
|
|
321
|
+
`[${CHANNEL_ID}] AccountId: ${accountId}, Blocked unauthorized sender (allowlist): sender=${chatId} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
|
|
311
322
|
);
|
|
312
323
|
return false;
|
|
313
324
|
}
|
|
@@ -326,7 +337,8 @@ async function parseGroupChat(
|
|
|
326
337
|
): Promise<boolean> {
|
|
327
338
|
const { accountId } = account;
|
|
328
339
|
payload.chatType = CHAT_TYPE_GROUP;
|
|
329
|
-
|
|
340
|
+
const chatId = msgData.group_id ? String(msgData.group_id).toLowerCase().trim() : '';
|
|
341
|
+
payload.chatId = chatId;
|
|
330
342
|
payload.groupName = msgData.group_name;
|
|
331
343
|
payload.msgId = msgData.msgid;
|
|
332
344
|
log?.debug?.(
|
|
@@ -337,7 +349,7 @@ async function parseGroupChat(
|
|
|
337
349
|
const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
|
|
338
350
|
const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
|
|
339
351
|
log?.debug?.(
|
|
340
|
-
`[${CHANNEL_ID}] AccountId: ${accountId}, groupPolicy=${groupPolicy} groupId=${
|
|
352
|
+
`[${CHANNEL_ID}] AccountId: ${accountId}, groupPolicy=${groupPolicy} groupId=${chatId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
|
|
341
353
|
);
|
|
342
354
|
|
|
343
355
|
if (groupPolicy === 'disabled') {
|
|
@@ -345,7 +357,7 @@ async function parseGroupChat(
|
|
|
345
357
|
return false;
|
|
346
358
|
}
|
|
347
359
|
|
|
348
|
-
if (!payload.tuituiAccount || !
|
|
360
|
+
if (!payload.tuituiAccount || !chatId) {
|
|
349
361
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in group_chat event`);
|
|
350
362
|
return false;
|
|
351
363
|
}
|
|
@@ -357,13 +369,16 @@ async function parseGroupChat(
|
|
|
357
369
|
|
|
358
370
|
payload.text = buildMessageBody(msgData);
|
|
359
371
|
|
|
360
|
-
|
|
361
|
-
|
|
372
|
+
if (!normalizedGroupAllowFrom.includes(String(chatId))) {
|
|
373
|
+
if (needPairingThrottle(accountId, chatId)) {
|
|
374
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, group pairing throttled for groupId=${chatId}`);
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
362
377
|
await sendTextMsg(
|
|
363
378
|
account,
|
|
364
|
-
|
|
379
|
+
chatId,
|
|
365
380
|
payload.chatType,
|
|
366
|
-
`当前openclaw
|
|
381
|
+
`当前openclaw(AccountId: ${accountId})群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${chatId}`,
|
|
367
382
|
'tuitui.groupPolicy.reply',
|
|
368
383
|
);
|
|
369
384
|
return false;
|
|
@@ -381,13 +396,15 @@ async function parseTeamsPost(
|
|
|
381
396
|
): Promise<boolean> {
|
|
382
397
|
const { accountId } = account;
|
|
383
398
|
payload.chatType = CHAT_TYPE_CHANNEL;
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
payload.
|
|
399
|
+
const { team_id, channel_id, parent_id, post_id, content } = msgData;
|
|
400
|
+
const thread_id = (parent_id && parent_id != "0")?parent_id: post_id;
|
|
401
|
+
const chatId = teamsBuildChatId(team_id, channel_id, thread_id);
|
|
402
|
+
payload.chatId = chatId;
|
|
403
|
+
payload.msgId = post_id;
|
|
404
|
+
payload.text = content;
|
|
388
405
|
payload.replyToId = "";
|
|
389
406
|
log?.debug?.(
|
|
390
|
-
`[${CHANNEL_ID}] inbound teams tuituiAccount=${payload.tuituiAccount} tuituiUid=${payload.tuituiUid} tuituiUserName=${payload.tuituiUserName} chatId=${
|
|
407
|
+
`[${CHANNEL_ID}] inbound teams tuituiAccount=${payload.tuituiAccount} tuituiUid=${payload.tuituiUid} tuituiUserName=${payload.tuituiUserName} chatId=${chatId}`,
|
|
391
408
|
);
|
|
392
409
|
|
|
393
410
|
// 暂时白名单先用这个,后面拆出来
|
|
@@ -395,7 +412,7 @@ async function parseTeamsPost(
|
|
|
395
412
|
const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
|
|
396
413
|
const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
|
|
397
414
|
log?.debug?.(
|
|
398
|
-
`[${CHANNEL_ID}] AccountId: ${accountId} groupPolicy=${groupPolicy} groupId=${
|
|
415
|
+
`[${CHANNEL_ID}] AccountId: ${accountId} groupPolicy=${groupPolicy} groupId=${chatId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
|
|
399
416
|
);
|
|
400
417
|
|
|
401
418
|
if (groupPolicy === 'disabled') {
|
|
@@ -403,7 +420,7 @@ async function parseTeamsPost(
|
|
|
403
420
|
return false;
|
|
404
421
|
}
|
|
405
422
|
|
|
406
|
-
if (!payload.tuituiAccount || !
|
|
423
|
+
if (!payload.tuituiAccount || !chatId) {
|
|
407
424
|
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, Missing tuituiAccount or chatId in event`);
|
|
408
425
|
return false;
|
|
409
426
|
}
|
|
@@ -413,13 +430,16 @@ async function parseTeamsPost(
|
|
|
413
430
|
return false;
|
|
414
431
|
}
|
|
415
432
|
|
|
416
|
-
|
|
417
|
-
|
|
433
|
+
if (!normalizedGroupAllowFrom.includes(String(team_id))) {
|
|
434
|
+
if (needPairingThrottle(accountId, chatId)) {
|
|
435
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId: ${accountId}, teams pairing throttled for teamsChatId=${chatId}`);
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
418
438
|
await sendTextMsg(
|
|
419
439
|
account,
|
|
420
|
-
|
|
440
|
+
chatId,
|
|
421
441
|
payload.chatType,
|
|
422
|
-
`当前openclaw
|
|
442
|
+
`当前openclaw(AccountId: ${accountId})群聊/团队策略为白名单,需要主人在群白名单(Group Allow From)增加当前团队ID:\n${team_id}`,
|
|
423
443
|
'tuitui.groupPolicy.reply',
|
|
424
444
|
);
|
|
425
445
|
return false;
|
package/src/outbound.ts
CHANGED
|
@@ -115,7 +115,7 @@ export async function postTuituiMsg(account: any, json: any, auditCtx: string):
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
export function checkAccount(account: any, ctxTips: string = 'send text') {
|
|
118
|
-
if (!account.appId || !account.appSecret) {
|
|
118
|
+
if (!account || !account.appId || !account.appSecret) {
|
|
119
119
|
throw new Error(`[${CHANNEL_ID}] appId and appSecret are required for ${ctxTips}`);
|
|
120
120
|
}
|
|
121
121
|
}
|
|
@@ -395,6 +395,59 @@ function replaceSingleNewlines(content: string): string {
|
|
|
395
395
|
return content.replace(/([^\n])\n([^\n])/g, '$1\n\n$2');
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
/*
|
|
399
|
+
从一个字符串中提取出来 @ 列表,匹配规则是
|
|
400
|
+
@之前的字符,只能是空格、回车、换行
|
|
401
|
+
@之后匹配到空格为止或者文本末尾
|
|
402
|
+
|
|
403
|
+
例子:
|
|
404
|
+
输入:你好 @张三 @李四 你吃了吗
|
|
405
|
+
输出数组:["张三","李四"]
|
|
406
|
+
*/
|
|
407
|
+
|
|
408
|
+
function getMentionsRegex(): RegExp {
|
|
409
|
+
// 正则表达式解释:
|
|
410
|
+
// (?<=^|[\s\r\n\u3000\u3001\u3002\uFF0C\uFF01\uFF1F\u2026])
|
|
411
|
+
// - 正向向后查找,确保@前面是:
|
|
412
|
+
// ^ - 字符串开头
|
|
413
|
+
// [\s\r\n] - 空格、回车、换行
|
|
414
|
+
// \u3000 - 中文全角空格
|
|
415
|
+
// \u3001 - 中文顿号(、)
|
|
416
|
+
// \u3002 - 中文句号(。)
|
|
417
|
+
// \uFF0C - 中文逗号(,)
|
|
418
|
+
// \uFF01 - 中文感叹号(!)
|
|
419
|
+
// \uFF1F - 中文问号(?)
|
|
420
|
+
// \u2026 - 中文省略号(…)
|
|
421
|
+
// @ - 匹配@符号
|
|
422
|
+
// ([^\s]+) - 捕获组,匹配一个或多个非空白字符
|
|
423
|
+
const regex = /(?<=^|[\s\r\n\u3000\u3001\u3002\uFF0C\uFF01\uFF1F\u2026])@([^\s]+)/g;
|
|
424
|
+
return regex
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function extractMentions(text: string): string[] {
|
|
428
|
+
const mentions: string[] = [];
|
|
429
|
+
|
|
430
|
+
const regex = getMentionsRegex();
|
|
431
|
+
let match;
|
|
432
|
+
while ((match = regex.exec(text)) !== null) {
|
|
433
|
+
const mention = match[1];
|
|
434
|
+
if (!mentions.includes(mention)) {
|
|
435
|
+
mentions.push(mention);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return mentions;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function replaceMentions(text: string): string {
|
|
443
|
+
const regex = getMentionsRegex();
|
|
444
|
+
return text.replace(regex, (match, mention) => {
|
|
445
|
+
// match: 完整的匹配字符串(例如 "@username")
|
|
446
|
+
// mention: 捕获组中的内容(例如 "username")
|
|
447
|
+
return `{{tuitui_at "${mention}"}}`;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
398
451
|
export async function sendTextMsg(
|
|
399
452
|
account: any,
|
|
400
453
|
chatId: string | undefined,
|
|
@@ -404,22 +457,30 @@ export async function sendTextMsg(
|
|
|
404
457
|
atList?: string[],
|
|
405
458
|
): Promise<void> {
|
|
406
459
|
if (!chatId) return console.error(`[${CHANNEL_ID}] sendTextMsg Error ${auditCtx}: Missing "target"`);
|
|
407
|
-
|
|
408
|
-
|
|
460
|
+
|
|
461
|
+
// 澄清:不再使用 atList 参数;统一从文本中提取 at 目标;
|
|
462
|
+
// 因为 atList 参数上层固定写死的填写为 发消息人,如果是机器人这样可能导致机器人之间at产生死循环。
|
|
409
463
|
if (chatType == CHAT_TYPE_CHANNEL) {
|
|
464
|
+
const content_with_newline = replaceSingleNewlines(content);
|
|
465
|
+
const content_with_mention = replaceMentions(content_with_newline);
|
|
466
|
+
const has_at = (content_with_mention != content_with_newline);
|
|
410
467
|
const msg: TuiTuiOutboundTeamsMarkdownMessage = {
|
|
411
468
|
...getTargets(chatId, chatType),
|
|
412
469
|
msgtype: 'richtext/markdown',
|
|
413
|
-
|
|
414
|
-
|
|
470
|
+
richtext: {
|
|
471
|
+
markdown: content_with_mention,
|
|
472
|
+
delims_left: has_at?"{{":"",
|
|
473
|
+
delims_right: has_at?"}}":"",
|
|
474
|
+
},
|
|
415
475
|
};
|
|
416
476
|
console.log(`[${CHANNEL_ID}] sendTeamsPost to ${chatId} ${auditCtx} - `, msg);
|
|
417
477
|
await postTuituiMsg(account, msg, auditCtx);
|
|
418
478
|
} else {
|
|
479
|
+
const at_from_text = chatType == CHAT_TYPE_GROUP?extractMentions(content):[];
|
|
419
480
|
const msg: TuiTuiOutboundTextMessage = {
|
|
420
481
|
...getTargets(chatId, chatType),
|
|
421
482
|
msgtype: 'text',
|
|
422
|
-
at:
|
|
483
|
+
at: at_from_text,
|
|
423
484
|
text: { content },
|
|
424
485
|
};
|
|
425
486
|
console.log(`[${CHANNEL_ID}] sendTextMsg to ${chatId} ${auditCtx} - `, msg);
|
package/src/command.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
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
|
-
}
|