@qihoo/tuitui-openclaw-channel 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/README.md +3 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +44 -0
- package/src/channel.ts +1147 -0
- package/src/types.ts +123 -0
- package/src/utils.ts +344 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TuiTui Channel Plugin for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* Implements the ChannelPlugin interface following the Synology Chat pattern.
|
|
5
|
+
* Supports single chat (text, image, voice, file) and group chat with @mentions.
|
|
6
|
+
*/
|
|
7
|
+
import WebSocket from 'ws';
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_ACCOUNT_ID,
|
|
11
|
+
setAccountEnabledInConfigSection,
|
|
12
|
+
deleteAccountFromConfigSection,
|
|
13
|
+
registerPluginHttpRoute,
|
|
14
|
+
buildChannelConfigSchema,
|
|
15
|
+
} from 'openclaw/plugin-sdk';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import type {
|
|
18
|
+
TuiTuiInboundMessage,
|
|
19
|
+
TuiTuiMessageData,
|
|
20
|
+
TuiTuiOutboundTextMessage,
|
|
21
|
+
TuiTuiOutboundImageMessage,
|
|
22
|
+
TuiTuiOutboundMessage,
|
|
23
|
+
} from './types';
|
|
24
|
+
import {
|
|
25
|
+
CHANNEL_ID,
|
|
26
|
+
CHANNEL_NAME,
|
|
27
|
+
TUITUI_SSRF_POLICY,
|
|
28
|
+
postTuituiMsg,
|
|
29
|
+
uploadMediaToTuiTui,
|
|
30
|
+
buildMessageBody,
|
|
31
|
+
tuituiEmojiReaction,
|
|
32
|
+
} from "./utils";
|
|
33
|
+
|
|
34
|
+
export const TuiTuiConfigSchema = buildChannelConfigSchema(
|
|
35
|
+
z
|
|
36
|
+
.object({
|
|
37
|
+
enabled: z.boolean().optional().describe('开启或关闭'),
|
|
38
|
+
appId: z.string().min(1).describe('推推机器人身份 AppId(你可以推推搜索【推推机器人助手】,和它聊天自助申请推推机器人)'),
|
|
39
|
+
appSecret: z.string().min(1).describe('推推机器人密钥 Secret'),
|
|
40
|
+
// 私聊策略(默认 pairing)
|
|
41
|
+
dmPolicy: z
|
|
42
|
+
.enum(['pairing', 'allowlist', 'open', 'disabled'])
|
|
43
|
+
.optional()
|
|
44
|
+
.default('pairing')
|
|
45
|
+
.describe('私聊策略:pairing=配对(默认);allowlist=白名单;open=允许所有(不安全);disabled=禁用私聊',),
|
|
46
|
+
// 私聊允许列表(当 dmPolicy=allowlist 生效;pairing 下也可显式允许)
|
|
47
|
+
allowFrom: z
|
|
48
|
+
.array(z.string())
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('私聊白名单(dmPolicy=allowlist 时生效;pairing 下可用于显式放行用户)'),
|
|
51
|
+
|
|
52
|
+
// 群组策略
|
|
53
|
+
groupPolicy: z
|
|
54
|
+
.enum(['allowlist', 'disabled'])
|
|
55
|
+
.default('allowlist')
|
|
56
|
+
.describe('群聊策略:allowlist=白名单;disabled=禁用群聊',),
|
|
57
|
+
// 仅在 allowlist 生效的群组 ID 列表
|
|
58
|
+
groupAllowFrom: z
|
|
59
|
+
.array(z.string())
|
|
60
|
+
.optional()
|
|
61
|
+
.describe('群组白名单(仅在 groupPolicy=allowlist 生效)'),
|
|
62
|
+
// 每个群组的覆盖配置
|
|
63
|
+
/*
|
|
64
|
+
groups: z
|
|
65
|
+
.record(
|
|
66
|
+
z.string(),
|
|
67
|
+
z
|
|
68
|
+
.object({
|
|
69
|
+
requireMention: z
|
|
70
|
+
.boolean()
|
|
71
|
+
.optional()
|
|
72
|
+
.describe('该群组是否需要 @ 机器人才触发 Agent(默认 true)'),
|
|
73
|
+
shouldReply: z
|
|
74
|
+
.boolean()
|
|
75
|
+
.optional()
|
|
76
|
+
.describe('该群组是否允许 Agent 回复(默认 true)'),
|
|
77
|
+
})
|
|
78
|
+
.passthrough(),
|
|
79
|
+
)
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('群组级覆盖配置'),
|
|
82
|
+
*/
|
|
83
|
+
})
|
|
84
|
+
.passthrough() as any,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
type TuiTuiConfig = any;
|
|
88
|
+
|
|
89
|
+
function resolveAccount(cfg: any, accountId?: string | null) {
|
|
90
|
+
const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
|
|
91
|
+
const targetId = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
92
|
+
const acct = targetId === DEFAULT_ACCOUNT_ID ? channelConfig : channelConfig.accounts?.[targetId];
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
accountId: targetId,
|
|
96
|
+
enabled: acct?.enabled ?? false,
|
|
97
|
+
appId: acct?.appId as string | undefined,
|
|
98
|
+
appSecret: acct?.appSecret as string | undefined,
|
|
99
|
+
dmPolicy: acct?.dmPolicy ?? "pairing",
|
|
100
|
+
allowFrom: acct?.allowFrom ?? [],
|
|
101
|
+
// 群组策略与白名单、群组级覆盖
|
|
102
|
+
groupPolicy: (acct?.groupPolicy as string | undefined) ?? "allowlist",
|
|
103
|
+
groupAllowFrom: Array.isArray(acct?.groupAllowFrom) ? acct.groupAllowFrom : [],
|
|
104
|
+
groups: (acct?.groups as Record<string, { requireMention?: boolean }> | undefined) ?? {},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isConfigured(account: any) {
|
|
109
|
+
return Boolean(
|
|
110
|
+
String(account?.appId ?? "").trim() &&
|
|
111
|
+
String(account?.appSecret ?? "").trim()
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
116
|
+
return {
|
|
117
|
+
id: CHANNEL_ID,
|
|
118
|
+
|
|
119
|
+
meta: {
|
|
120
|
+
id: CHANNEL_ID,
|
|
121
|
+
label: CHANNEL_NAME,
|
|
122
|
+
selectionLabel: CHANNEL_NAME,
|
|
123
|
+
detailLabel: CHANNEL_NAME,
|
|
124
|
+
docsPath: `/channels/${CHANNEL_ID}`,
|
|
125
|
+
blurb: `Connect to ${CHANNEL_NAME} bot via WebSocket`,
|
|
126
|
+
order: 100,
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
capabilities: {
|
|
130
|
+
chatTypes: ["direct" as const, "group" as const],
|
|
131
|
+
media: true,
|
|
132
|
+
threads: false,
|
|
133
|
+
reactions: false,
|
|
134
|
+
edit: false,
|
|
135
|
+
unsend: false,
|
|
136
|
+
reply: true,
|
|
137
|
+
effects: false,
|
|
138
|
+
blockStreaming: false,
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
142
|
+
|
|
143
|
+
configSchema: TuiTuiConfigSchema,
|
|
144
|
+
|
|
145
|
+
config: {
|
|
146
|
+
listAccountIds: (cfg: any) => {
|
|
147
|
+
const base = cfg?.channels?.[CHANNEL_ID];
|
|
148
|
+
if (!base) return [];
|
|
149
|
+
const ids = new Set<string>();
|
|
150
|
+
if (base.enabled !== undefined) {
|
|
151
|
+
ids.add(DEFAULT_ACCOUNT_ID);
|
|
152
|
+
}
|
|
153
|
+
if (base.accounts) {
|
|
154
|
+
for (const id of Object.keys(base.accounts)) ids.add(id);
|
|
155
|
+
}
|
|
156
|
+
return Array.from(ids);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
|
|
160
|
+
|
|
161
|
+
defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID,
|
|
162
|
+
|
|
163
|
+
isEnabled: (account: any) => account?.enabled !== false,
|
|
164
|
+
|
|
165
|
+
isConfigured,
|
|
166
|
+
|
|
167
|
+
describeAccount: (account: any) => ({
|
|
168
|
+
accountId: account?.accountId || DEFAULT_ACCOUNT_ID,
|
|
169
|
+
enabled: account?.enabled !== false,
|
|
170
|
+
configured: isConfigured(account),
|
|
171
|
+
}),
|
|
172
|
+
|
|
173
|
+
setAccountEnabled: ({ cfg, accountId, enabled }: any) => {
|
|
174
|
+
const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
|
|
175
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
176
|
+
return {
|
|
177
|
+
...cfg,
|
|
178
|
+
channels: {
|
|
179
|
+
...cfg.channels,
|
|
180
|
+
[CHANNEL_ID]: { ...channelConfig, enabled },
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return setAccountEnabledInConfigSection({
|
|
185
|
+
cfg,
|
|
186
|
+
sectionKey: CHANNEL_ID,
|
|
187
|
+
accountId,
|
|
188
|
+
enabled,
|
|
189
|
+
allowTopLevel: true,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
setAccountEnabled: ({ cfg, accountId, enabled }: any) => {
|
|
194
|
+
const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
|
|
195
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
196
|
+
return {
|
|
197
|
+
...cfg,
|
|
198
|
+
channels: {
|
|
199
|
+
...cfg.channels,
|
|
200
|
+
[CHANNEL_ID]: { ...channelConfig, enabled },
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
return setAccountEnabledInConfigSection({
|
|
205
|
+
cfg,
|
|
206
|
+
sectionKey: CHANNEL_ID,
|
|
207
|
+
accountId,
|
|
208
|
+
enabled,
|
|
209
|
+
allowTopLevel: true,
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
deleteAccount: ({ cfg, accountId }: any) => {
|
|
214
|
+
const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
|
|
215
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
216
|
+
// For default account, we don't delete the entire config
|
|
217
|
+
// Instead, we disable it and clear sensitive fields
|
|
218
|
+
return {
|
|
219
|
+
...cfg,
|
|
220
|
+
channels: {
|
|
221
|
+
...cfg.channels,
|
|
222
|
+
[CHANNEL_ID]: {
|
|
223
|
+
...channelConfig,
|
|
224
|
+
enabled: false,
|
|
225
|
+
appId: undefined,
|
|
226
|
+
appSecret: undefined,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
// For named accounts, use the standard delete function
|
|
232
|
+
return deleteAccountFromConfigSection({
|
|
233
|
+
cfg,
|
|
234
|
+
sectionKey: CHANNEL_ID,
|
|
235
|
+
accountId,
|
|
236
|
+
clearBaseFields: [
|
|
237
|
+
"appId",
|
|
238
|
+
"appSecret",
|
|
239
|
+
"dmPolicy",
|
|
240
|
+
"allowFrom",
|
|
241
|
+
"groupPolicy",
|
|
242
|
+
"groupAllowFrom",
|
|
243
|
+
"groups",
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
pairing: {
|
|
250
|
+
idLabel: "tuituiUserId",
|
|
251
|
+
normalizeAllowEntry: (entry: string) => entry.toLowerCase().trim(),
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
status: {
|
|
255
|
+
buildAccountSnapshot: (params: any) => {
|
|
256
|
+
const account = params.account;
|
|
257
|
+
return {
|
|
258
|
+
...params.runtime,
|
|
259
|
+
accountId: params.accountId ?? account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
260
|
+
enabled: account?.enabled !== false,
|
|
261
|
+
configured: isConfigured(account),
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
security: {
|
|
267
|
+
resolveDmPolicy: ({
|
|
268
|
+
cfg,
|
|
269
|
+
accountId,
|
|
270
|
+
account: ctxAccount,
|
|
271
|
+
}: {
|
|
272
|
+
cfg: any;
|
|
273
|
+
accountId?: string | null;
|
|
274
|
+
account?: any;
|
|
275
|
+
}) => {
|
|
276
|
+
const account = ctxAccount ?? resolveAccount(cfg, accountId);
|
|
277
|
+
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
278
|
+
const basePath =
|
|
279
|
+
resolvedAccountId === DEFAULT_ACCOUNT_ID
|
|
280
|
+
? `channels.${CHANNEL_ID}.`
|
|
281
|
+
: `channels.${CHANNEL_ID}.accounts.${resolvedAccountId}.`;
|
|
282
|
+
|
|
283
|
+
const policy = account.dmPolicy ?? "pairing";
|
|
284
|
+
// dmPolicy semantics:
|
|
285
|
+
// - open: always allow everyone (["*"]), ignore allowFrom values.
|
|
286
|
+
// - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
|
|
287
|
+
// - allowlist: only allow explicitly listed users in allowFrom.
|
|
288
|
+
// - disabled: block all DMs.
|
|
289
|
+
const allowFrom = policy === "open" ? ["*"] : (account.allowFrom ?? []);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
policy,
|
|
293
|
+
allowFrom,
|
|
294
|
+
policyPath: `${basePath}dmPolicy`,
|
|
295
|
+
allowFromPath: basePath,
|
|
296
|
+
approveHint: `openclaw pairing approve ${CHANNEL_ID} <code>`,
|
|
297
|
+
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
messaging: {
|
|
303
|
+
normalizeTarget: (target: string) => {
|
|
304
|
+
return target.trim();
|
|
305
|
+
},
|
|
306
|
+
targetResolver: {
|
|
307
|
+
looksLikeId: (id: string) => {
|
|
308
|
+
return Boolean(id && id.trim().length > 0);
|
|
309
|
+
},
|
|
310
|
+
hint: "<userId>",
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
directory: {
|
|
315
|
+
self: async () => null,
|
|
316
|
+
listPeers: async () => [],
|
|
317
|
+
listGroups: async () => [],
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
outbound: {
|
|
321
|
+
deliveryMode: "gateway" as const,
|
|
322
|
+
textChunkLimit: 2000,
|
|
323
|
+
|
|
324
|
+
sendText: async ({
|
|
325
|
+
cfg,
|
|
326
|
+
to,
|
|
327
|
+
text,
|
|
328
|
+
accountId,
|
|
329
|
+
account: ctxAccount,
|
|
330
|
+
log,
|
|
331
|
+
}: any) => {
|
|
332
|
+
const account = ctxAccount ?? resolveAccount(cfg, accountId);
|
|
333
|
+
|
|
334
|
+
console.log(`[${CHANNEL_ID}] sendText - to: ${to}, accountId: ${accountId}, text: ${text}; account: ${account}`);
|
|
335
|
+
|
|
336
|
+
if (!account.appId || !account.appSecret) {
|
|
337
|
+
throw new Error(`[${CHANNEL_ID}] appId and appSecret are required for sending text`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Determine if this is a group message based on 'to' being all digits (group) or not (direct)
|
|
341
|
+
const toStr = String(to ?? "").trim();
|
|
342
|
+
const isGroup = /^\d+$/.test(toStr);
|
|
343
|
+
const targetGroupId = isGroup ? toStr : undefined;
|
|
344
|
+
const targetUserId = isGroup ? undefined : toStr;
|
|
345
|
+
|
|
346
|
+
// Format message according to TuiTui's required structure
|
|
347
|
+
const payload: TuiTuiOutboundTextMessage = {
|
|
348
|
+
tousers: targetUserId ? [targetUserId] : [],
|
|
349
|
+
togroups: targetGroupId ? [targetGroupId] : [],
|
|
350
|
+
at: [],
|
|
351
|
+
msgtype: "text",
|
|
352
|
+
text: {
|
|
353
|
+
content: text,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
log?.info?.(
|
|
358
|
+
`[${CHANNEL_ID}] sending text to TuiTui -
|
|
359
|
+
account=${accountId ?? "default"},
|
|
360
|
+
to=${to},
|
|
361
|
+
target_users: [${payload.tousers.join(",")}],
|
|
362
|
+
target_groups: [${payload.togroups.join(",")}],
|
|
363
|
+
isGroup: ${isGroup},
|
|
364
|
+
textLength=${String(text ?? "").length}`
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
await postTuituiMsg(account, payload, 'tuitui.send.text', log);
|
|
368
|
+
|
|
369
|
+
return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: to };
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
sendCustom: async ({
|
|
373
|
+
cfg,
|
|
374
|
+
to,
|
|
375
|
+
payload: customPayload,
|
|
376
|
+
accountId,
|
|
377
|
+
account: ctxAccount,
|
|
378
|
+
chatType,
|
|
379
|
+
groupId,
|
|
380
|
+
log,
|
|
381
|
+
}: any) => {
|
|
382
|
+
const account = ctxAccount ?? resolveAccount(cfg, accountId);
|
|
383
|
+
|
|
384
|
+
if (!account.appId || !account.appSecret) {
|
|
385
|
+
throw new Error(`[${CHANNEL_ID}] appId and appSecret are required for sending custom message`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Determine if this is a group message
|
|
389
|
+
// WORKAROUND: OpenClaw core doesn't pass chatType/groupId to sendPayload.
|
|
390
|
+
// If `to` is a numeric string and chatType/groupId are undefined, assume it's a group.
|
|
391
|
+
const toIsNumeric = typeof to === 'string' && /^\d+$/.test(to);
|
|
392
|
+
const inferredIsGroup = chatType === "group" || groupId || (toIsNumeric && chatType === undefined && groupId === undefined);
|
|
393
|
+
const isGroup = inferredIsGroup;
|
|
394
|
+
const targetGroupId = groupId || (isGroup ? to : undefined);
|
|
395
|
+
const targetUserId = isGroup ? undefined : to;
|
|
396
|
+
|
|
397
|
+
// If it's a page message, we need to construct it
|
|
398
|
+
if (customPayload?.msgtype === "page") {
|
|
399
|
+
const payload: any = {
|
|
400
|
+
tousers: targetUserId ? [targetUserId] : [],
|
|
401
|
+
togroups: targetGroupId ? [targetGroupId] : [],
|
|
402
|
+
msgtype: "page",
|
|
403
|
+
page: {
|
|
404
|
+
...customPayload.page
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
if (customPayload.tousers) {
|
|
409
|
+
payload.tousers = customPayload.tousers;
|
|
410
|
+
}
|
|
411
|
+
if (customPayload.togroups) {
|
|
412
|
+
payload.togroups = customPayload.togroups;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
log?.info?.(
|
|
416
|
+
`[${CHANNEL_ID}] sending page to TuiTui -
|
|
417
|
+
target_users: [${payload.tousers.join(",")}],
|
|
418
|
+
target_groups: [${payload.togroups.join(",")}],
|
|
419
|
+
chatType: ${chatType ?? 'direct'},
|
|
420
|
+
account=${accountId ?? 'default'},
|
|
421
|
+
chatType=${chatType ?? 'direct'},
|
|
422
|
+
to=${to},
|
|
423
|
+
groupId=${groupId ?? '-'}`,
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
await postTuituiMsg(account, payload, 'tuitui.send.page', log);
|
|
427
|
+
|
|
428
|
+
return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: to };
|
|
429
|
+
} else {
|
|
430
|
+
throw new Error(`[${CHANNEL_ID}] unsupported custom message type: ${customPayload?.msgtype}`);
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
sendMedia: async ({
|
|
435
|
+
cfg,
|
|
436
|
+
to,
|
|
437
|
+
mediaUrl,
|
|
438
|
+
accountId,
|
|
439
|
+
account: ctxAccount,
|
|
440
|
+
log,
|
|
441
|
+
}: any) => {
|
|
442
|
+
const account = ctxAccount ?? resolveAccount(cfg, accountId);
|
|
443
|
+
|
|
444
|
+
// sendMedia params sanitized; detailed logging added below
|
|
445
|
+
|
|
446
|
+
if (!account.appId || !account.appSecret) {
|
|
447
|
+
throw new Error(`[${CHANNEL_ID}] appId and appSecret are required for sending media`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Determine if this is a group message based on 'to' being all digits (group) or not (direct)
|
|
451
|
+
const toStr = String(to ?? "").trim();
|
|
452
|
+
const isGroup = /^\d+$/.test(toStr);
|
|
453
|
+
const targetGroupId = isGroup ? toStr : undefined;
|
|
454
|
+
const targetUserId = isGroup ? undefined : toStr;
|
|
455
|
+
|
|
456
|
+
// Detect image/file by URL pattern first; fallback to upload API type auto-detection when needed.
|
|
457
|
+
// NOTE: relying on `mediaUrl.includes("image")` is too broad and can misclassify files.
|
|
458
|
+
const isImageByExt = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(?:$|[?#])/i.test(mediaUrl);
|
|
459
|
+
const isDataUrlImage = /^data:image\//i.test(mediaUrl);
|
|
460
|
+
const isImage = isImageByExt || isDataUrlImage;
|
|
461
|
+
const uploadType: "image" | "file" = isImage ? "image" : "file";
|
|
462
|
+
|
|
463
|
+
log?.debug?.(
|
|
464
|
+
`[${CHANNEL_ID}] sendMedia classify account=${accountId ?? "default"} to=${toStr} isGroup=${isGroup} mediaUrl=${mediaUrl} isImageByExt=${isImageByExt} isDataUrlImage=${isDataUrlImage} uploadType=${uploadType}`,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
let payload: TuiTuiOutboundMessage;
|
|
468
|
+
|
|
469
|
+
const mediaId = await uploadMediaToTuiTui(
|
|
470
|
+
mediaUrl,
|
|
471
|
+
account.appId,
|
|
472
|
+
account.appSecret,
|
|
473
|
+
uploadType,
|
|
474
|
+
);
|
|
475
|
+
log?.debug?.(`[${CHANNEL_ID}] sendMedia upload done type=${uploadType} mediaId=${mediaId}`);
|
|
476
|
+
|
|
477
|
+
// Keep outbound msgtype consistent with upload type.
|
|
478
|
+
// 群消息:togroups 填群ID,tousers 是空数组;私聊:tousers 填 to;均不设置 at
|
|
479
|
+
payload =
|
|
480
|
+
uploadType === "image"
|
|
481
|
+
? {
|
|
482
|
+
tousers: targetUserId ? [targetUserId] : [],
|
|
483
|
+
togroups: targetGroupId ? [targetGroupId] : [],
|
|
484
|
+
msgtype: "image",
|
|
485
|
+
image: {
|
|
486
|
+
media_id: mediaId,
|
|
487
|
+
},
|
|
488
|
+
}
|
|
489
|
+
: {
|
|
490
|
+
tousers: targetUserId ? [targetUserId] : [],
|
|
491
|
+
togroups: targetGroupId ? [targetGroupId] : [],
|
|
492
|
+
msgtype: "attachment",
|
|
493
|
+
attachment: {
|
|
494
|
+
media_id: mediaId,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const infoTousers = Array.isArray((payload as any).tousers) ? (payload as any).tousers : [];
|
|
499
|
+
const infoTogroups = Array.isArray((payload as any).togroups) ? (payload as any).togroups : [];
|
|
500
|
+
log?.info?.(
|
|
501
|
+
`[${CHANNEL_ID}] sending media to TuiTui -
|
|
502
|
+
target_users: [${infoTousers.join(",")}],
|
|
503
|
+
target_groups: [${infoTogroups.join(",")}],
|
|
504
|
+
type: ${payload.msgtype},
|
|
505
|
+
isGroup=${isGroup},
|
|
506
|
+
msgtype=${payload.msgtype},
|
|
507
|
+
account=${accountId ?? "default"},
|
|
508
|
+
to=${to},
|
|
509
|
+
isGroup=${isGroup},
|
|
510
|
+
payload=${JSON.stringify(payload)},
|
|
511
|
+
targetGroupId=${targetGroupId},
|
|
512
|
+
targetUserId=${targetUserId},
|
|
513
|
+
uploadType=${uploadType},
|
|
514
|
+
mediaId=${mediaId}`
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
await postTuituiMsg(account, payload, 'tuitui.send.media', log);
|
|
518
|
+
|
|
519
|
+
return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: to };
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
gateway: {
|
|
524
|
+
startAccount: async (ctx: any) => {
|
|
525
|
+
const { cfg, accountId, log, abortSignal, setStatus, getStatus } = ctx;
|
|
526
|
+
const _setStatus = (obj = {}) => {
|
|
527
|
+
if (!setStatus || !getStatus) return;
|
|
528
|
+
setStatus({ ...getStatus(), running: false, connected: false, ...obj });
|
|
529
|
+
};
|
|
530
|
+
const account = resolveAccount(cfg, accountId);
|
|
531
|
+
|
|
532
|
+
if (!account.enabled) {
|
|
533
|
+
log?.info?.(`[${CHANNEL_ID}] account ${accountId} is disabled, skipping`);
|
|
534
|
+
return _setStatus();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!isConfigured(account)) {
|
|
538
|
+
log?.warn?.(`[${CHANNEL_ID}] account ${accountId} not fully configured (missing appId/appSecret)`);
|
|
539
|
+
return _setStatus();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
log?.info?.(`[${CHANNEL_ID}] Starting TuiTui channel (account: ${accountId})`);
|
|
543
|
+
_setStatus({ running: true });
|
|
544
|
+
let ws: any = null;
|
|
545
|
+
const wsUrl = `wss://im.live.360.cn:8282/robot/callback/ws?auth=${account.appId}.${account.appSecret}`;
|
|
546
|
+
const defSendMsg = (msg) => {
|
|
547
|
+
log?.info?.('[${CHANNEL_ID}] WebSocket.Error 环境未就绪,消息发送失败', msg);
|
|
548
|
+
};
|
|
549
|
+
let _sendMsg = defSendMsg;
|
|
550
|
+
const startWebSocket = () => {
|
|
551
|
+
log?.info?.(`[${CHANNEL_ID}] Registered WebSocket URL: ${wsUrl} for TuiTui`);
|
|
552
|
+
ws = new WebSocket(wsUrl, { rejectUnauthorized: true });
|
|
553
|
+
ws.on('open', () => {
|
|
554
|
+
log?.info?.(`[${CHANNEL_ID}] WebSocket connect success`);
|
|
555
|
+
_setStatus({ running: true, connected: true });
|
|
556
|
+
_sendMsg = (msg) => ws.send(msg);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// on receiving messages from tuitui websocket server
|
|
560
|
+
ws.on('message', async (wsData: string) => {
|
|
561
|
+
let json = null;
|
|
562
|
+
try {
|
|
563
|
+
json = JSON.parse(wsData);
|
|
564
|
+
// 收到任意消息则回复一下,权当“收到”
|
|
565
|
+
if (json.event_id) _sendMsg(`{ "ack": "${json.event_id}" }`);
|
|
566
|
+
} catch(err) {
|
|
567
|
+
log?.warn?.(`[${CHANNEL_ID}] WebSocket Message Is Invalid JSON: ${wsData}`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const wsEvent = json?.body?.event;
|
|
571
|
+
const isKeepalive = wsEvent === 'keepalive';
|
|
572
|
+
|
|
573
|
+
// 心跳日志太多了,滤掉,异常信息上面 JSON.parse catch 会输出
|
|
574
|
+
if (!isKeepalive) log?.info?.(`[${CHANNEL_ID}] Received TuiTui WebSocket message: ${wsData}`);
|
|
575
|
+
|
|
576
|
+
// 忽略无效信息、心跳信息
|
|
577
|
+
if (!json?.header || !wsEvent || isKeepalive) return;
|
|
578
|
+
|
|
579
|
+
// Preferred TuiTui signature validation:
|
|
580
|
+
// X-Tuitui-Robot-Checksum = sha1(app_secret + timestamp + nonce + raw_json_body)
|
|
581
|
+
// Also verifies appid in header matches configured appId.
|
|
582
|
+
const { appId, appSecret } = account;
|
|
583
|
+
if (appSecret) {
|
|
584
|
+
const hdAppId = json.header['X-Tuitui-Robot-Appid'];
|
|
585
|
+
const hdTs = json.header['X-Tuitui-Robot-Timestamp'];
|
|
586
|
+
const hdNonce = json.header['X-Tuitui-Robot-Nonce'];
|
|
587
|
+
const hdChecksum = json.header['X-Tuitui-Robot-Checksum'];
|
|
588
|
+
|
|
589
|
+
if (!hdAppId || !hdTs || !hdNonce || !hdChecksum) {
|
|
590
|
+
log?.info?.(`[${CHANNEL_ID}] Missing TuiTui authentication headers`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (appId && hdAppId !== appId) {
|
|
595
|
+
log?.info?.(`[${CHANNEL_ID}] Invalid appId`);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Parse TuiTui message format
|
|
601
|
+
// userAccount: 推推用户账号,用于 tousers(发送消息的目标用户)
|
|
602
|
+
const msg = json.body as TuiTuiInboundMessage;
|
|
603
|
+
let userAccount: string | undefined;
|
|
604
|
+
let userName: string | undefined;
|
|
605
|
+
let userUid: string | undefined;
|
|
606
|
+
let chatType: "direct" | "group";
|
|
607
|
+
let text: string;
|
|
608
|
+
let groupId: string | undefined;
|
|
609
|
+
let groupName: string | undefined;
|
|
610
|
+
let mediaUrls: string[] | undefined;
|
|
611
|
+
let replyToId: string | undefined;
|
|
612
|
+
let suppressReply = false; // 是否抑制回复(用于 shouldReply: false 且没有 @ 机器人的情况)
|
|
613
|
+
|
|
614
|
+
// Handle different event types
|
|
615
|
+
if (wsEvent === 'single_chat' && msg.data) {
|
|
616
|
+
// Single chat message
|
|
617
|
+
userAccount = msg.user_account;
|
|
618
|
+
userUid = msg.uid;
|
|
619
|
+
userName = msg.user_name;
|
|
620
|
+
chatType = "direct";
|
|
621
|
+
text = buildMessageBody(msg.data);
|
|
622
|
+
|
|
623
|
+
log?.debug?.(
|
|
624
|
+
`[${CHANNEL_ID}] inbound single_chat user_account=${String(userAccount)} uid=${String(userUid)} user_name=${String(msg.user_name)}`,
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
// Extract media URLs for image/voice/file messages
|
|
628
|
+
if (
|
|
629
|
+
(msg.data.msg_type === "image" || msg.data.msg_type === "mixed") &&
|
|
630
|
+
msg.data.images?.length
|
|
631
|
+
) {
|
|
632
|
+
mediaUrls = msg.data.images;
|
|
633
|
+
} else if (msg.data.msg_type === "voice" && msg.data.voice) {
|
|
634
|
+
mediaUrls = [msg.data.voice];
|
|
635
|
+
} else if (msg.data.msg_type === "file" && msg.data.file) {
|
|
636
|
+
mediaUrls = [msg.data.file.url];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Handle reference/reply
|
|
640
|
+
if (msg.data.ref?.is_me && msg.data.ref.msgid) {
|
|
641
|
+
replyToId = msg.data.ref.msgid;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (!userAccount && !userUid) {
|
|
645
|
+
log?.info?.(`[${CHANNEL_ID}] Missing user_account or uid in single_chat event`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
} else if (wsEvent === "group_chat" && msg.data) {
|
|
649
|
+
// Group chat message - only process if bot is mentioned
|
|
650
|
+
userAccount = msg.user_account;
|
|
651
|
+
userUid = msg.uid;
|
|
652
|
+
userName = msg.user_name;
|
|
653
|
+
chatType = "group";
|
|
654
|
+
groupId = msg.data.group_id;
|
|
655
|
+
groupName = msg.data.group_name;
|
|
656
|
+
|
|
657
|
+
log?.debug?.(
|
|
658
|
+
`[${CHANNEL_ID}] inbound group_chat user_account=${String(userAccount)} uid=${String(userUid)} user_name=${String(msg.user_name)} group_id=${String(groupId)}`,
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
// Group policy gating and @mention requirements
|
|
662
|
+
const groupPolicy = String(account.groupPolicy ?? "allowlist").toLowerCase();
|
|
663
|
+
const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
|
|
664
|
+
const normalizedGroupAllowFrom = groupAllowFromRaw
|
|
665
|
+
.filter((v: unknown) => v != null)
|
|
666
|
+
.map((v: unknown) => String(v).trim())
|
|
667
|
+
.filter(Boolean);
|
|
668
|
+
log?.debug?.(
|
|
669
|
+
`[${CHANNEL_ID}] groupPolicy=${groupPolicy} groupId=${String(groupId)} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msg.data.at_me)} at=${JSON.stringify(msg.data.at)}`,
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
if (groupPolicy === "disabled") {
|
|
673
|
+
log?.info?.("Groups disabled");
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
// 群消息处理策略
|
|
679
|
+
const groupCfg = account.groups?.[String(groupId)];
|
|
680
|
+
|
|
681
|
+
// @机器人触发策略
|
|
682
|
+
let requireMention = true;
|
|
683
|
+
if (groupCfg && typeof groupCfg.requireMention === "boolean") {
|
|
684
|
+
requireMention = groupCfg.requireMention;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// 是否需要回复
|
|
688
|
+
let shouldReply = true;
|
|
689
|
+
if (groupCfg && typeof groupCfg.shouldReply === "boolean") {
|
|
690
|
+
shouldReply = groupCfg.shouldReply;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// 群消息处理逻辑:
|
|
694
|
+
// 1. requireMention: false, shouldReply: true -> 接收所有群消息并自动回复(无论是否 @)
|
|
695
|
+
// 2. requireMention: true, shouldReply: true -> 仅当 @ 机器人时才触发 Agent 并回复,不 @ 的消息直接忽略
|
|
696
|
+
// 3. requireMention: true, shouldReply: false -> 接收所有群消息触发 Agent,但仅当 @ 机器人时才回复
|
|
697
|
+
|
|
698
|
+
if (msg.data.at_me) {
|
|
699
|
+
// 消息中 @ 了机器人,必须触发 Agent 并回复(即使 shouldReply: false)
|
|
700
|
+
text = buildMessageBody(msg.data);
|
|
701
|
+
if (!userAccount || !groupId) {
|
|
702
|
+
log?.info?.(`[${CHANNEL_ID}] Missing user_account or group_id in group_chat event`);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
// 继续执行后续的 Agent 处理逻辑
|
|
706
|
+
} else if (!requireMention) {
|
|
707
|
+
// 消息中没有 @ 机器人,但配置为自动回复(无需 @)
|
|
708
|
+
// 触发 Agent 并回复
|
|
709
|
+
text = buildMessageBody(msg.data);
|
|
710
|
+
if (!userAccount || !groupId) {
|
|
711
|
+
log?.info?.(`[${CHANNEL_ID}] Missing user_account or group_id in group_chat event`);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
// 继续执行后续的 Agent 处理逻辑
|
|
715
|
+
} else if (!shouldReply) {
|
|
716
|
+
// 消息中没有 @ 机器人,requireMention=true,shouldReply=false
|
|
717
|
+
// 触发 Agent 但不回复(Agent 可以处理消息,但 deliver 不会发送回复)
|
|
718
|
+
text = buildMessageBody(msg.data);
|
|
719
|
+
suppressReply = true; // 标记为抑制回复
|
|
720
|
+
if (!userAccount || !groupId) {
|
|
721
|
+
log?.info?.(`[${CHANNEL_ID}] Missing user_account or group_id in group_chat event`);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
// 继续执行后续的 Agent 处理逻辑
|
|
725
|
+
} else {
|
|
726
|
+
// 默认情况:不 @ 机器人,requireMention=true,shouldReply=true
|
|
727
|
+
// 忽略消息
|
|
728
|
+
log?.info?.(`[${CHANNEL_ID}] ignore (not mentioned)`);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (!userAccount || !groupId) {
|
|
733
|
+
log?.info?.(`[${CHANNEL_ID}] Missing user_account or group_id in group_chat event`);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (
|
|
738
|
+
!normalizedGroupAllowFrom.includes(String(groupId))
|
|
739
|
+
) {
|
|
740
|
+
log?.info?.(`[${CHANNEL_ID}] Group ${groupId} not allowed`);
|
|
741
|
+
|
|
742
|
+
const payload: TuiTuiOutboundTextMessage = {
|
|
743
|
+
tousers: [],
|
|
744
|
+
togroups: [groupId],
|
|
745
|
+
at: [],
|
|
746
|
+
msgtype: "text",
|
|
747
|
+
text: { content: `当前openclaw群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${groupId}` },
|
|
748
|
+
};
|
|
749
|
+
await postTuituiMsg(account, payload, 'tuitui.groupPolicy.reply', log);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
} else {
|
|
754
|
+
log?.info?.(`[${CHANNEL_ID}] ignore unknown event ${wsEvent}`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Enforce DM allowlist at ingress to guarantee closed policy blocks unauthorized senders.
|
|
759
|
+
// We intentionally enforce here (in addition to downstream guards) because plugin runtime
|
|
760
|
+
// paths may vary between local/dev/prod setups.
|
|
761
|
+
|
|
762
|
+
// DM access gating (pairing/allowlist/open/disabled)
|
|
763
|
+
const dmPolicy = String(account.dmPolicy ?? "pairing").toLowerCase();
|
|
764
|
+
const configuredAllowFrom = Array.isArray(account.allowFrom) ? account.allowFrom : [];
|
|
765
|
+
const normalizedAllowFrom = configuredAllowFrom
|
|
766
|
+
.filter((v: unknown) => v != null)
|
|
767
|
+
.map((v: unknown) => String(v).toLowerCase().trim())
|
|
768
|
+
.filter(Boolean);
|
|
769
|
+
// 只使用 userAccount 作为匹配依据,因为用户希望 allowFrom 匹配 user_account
|
|
770
|
+
const senderForPolicy = userAccount ? String(userAccount).toLowerCase().trim() : "";
|
|
771
|
+
log?.debug?.(
|
|
772
|
+
`[${CHANNEL_ID}] dmPolicy=${dmPolicy} userAccount=${String(userAccount)} userUid=${String(userUid)} senderForPolicy=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
if (chatType === "direct") {
|
|
776
|
+
if (dmPolicy === "disabled") {
|
|
777
|
+
log?.warn?.(`[${CHANNEL_ID}] DM blocked (disabled) sender=${senderForPolicy}`);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (dmPolicy !== "open") {
|
|
782
|
+
// Merge pairing-store entries unless policy is allowlist-only
|
|
783
|
+
let storeAllowFrom: string[] = [];
|
|
784
|
+
try {
|
|
785
|
+
if (dmPolicy !== "allowlist") {
|
|
786
|
+
const entries = await apiRuntime?.channel?.pairing?.readAllowFromStore?.({
|
|
787
|
+
channel: CHANNEL_ID,
|
|
788
|
+
accountId: account.accountId,
|
|
789
|
+
});
|
|
790
|
+
if (Array.isArray(entries)) {
|
|
791
|
+
storeAllowFrom = entries
|
|
792
|
+
.map((v: unknown) => String(v).toLowerCase().trim())
|
|
793
|
+
.filter(Boolean);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
} catch {
|
|
797
|
+
storeAllowFrom = [];
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 只检查 userAccount 是否在 allowFrom 或 storeAllowFrom 中
|
|
801
|
+
const allowSet = new Set([...normalizedAllowFrom, ...storeAllowFrom]);
|
|
802
|
+
const isAllowed = userAccount ? allowSet.has(senderForPolicy) : false;
|
|
803
|
+
|
|
804
|
+
if (!isAllowed) {
|
|
805
|
+
if (dmPolicy === "pairing") {
|
|
806
|
+
try {
|
|
807
|
+
log?.debug?.(
|
|
808
|
+
`[${CHANNEL_ID}] pairing flow: checking if pairing request exists for sender=${senderForPolicy}`,
|
|
809
|
+
);
|
|
810
|
+
const req = await apiRuntime?.channel?.pairing?.upsertPairingRequest?.({
|
|
811
|
+
channel: CHANNEL_ID,
|
|
812
|
+
accountId: account.accountId,
|
|
813
|
+
id: senderForPolicy,
|
|
814
|
+
});
|
|
815
|
+
log?.debug?.(
|
|
816
|
+
`[${CHANNEL_ID}] pairing flow: upsertPairingRequest result=${JSON.stringify(req)}`,
|
|
817
|
+
);
|
|
818
|
+
/***
|
|
819
|
+
* 把 req?.created 注释掉。不然一旦发消息失败,就进入死局。
|
|
820
|
+
* 删除这个标志,只要没配对,会每次和机器人聊,都会返回配对信息。
|
|
821
|
+
*/
|
|
822
|
+
if (/*req?.created && */ account.appId && account.appSecret) {
|
|
823
|
+
const replyText =
|
|
824
|
+
apiRuntime?.channel?.pairing?.buildPairingReply?.({
|
|
825
|
+
channel: CHANNEL_ID,
|
|
826
|
+
code: req.code,
|
|
827
|
+
}) ?? "Pairing required. Ask the bot owner to approve.";
|
|
828
|
+
|
|
829
|
+
// tousers 使用 userAccount(推推用户账号)
|
|
830
|
+
const pairingTarget = userAccount;
|
|
831
|
+
log?.debug?.(
|
|
832
|
+
`[${CHANNEL_ID}] pairing.reply target=${String(pairingTarget)}`,
|
|
833
|
+
);
|
|
834
|
+
const payload: TuiTuiOutboundTextMessage = {
|
|
835
|
+
tousers: pairingTarget ? [String(pairingTarget)] : [],
|
|
836
|
+
togroups: [],
|
|
837
|
+
at: [],
|
|
838
|
+
msgtype: "text",
|
|
839
|
+
text: { content: replyText },
|
|
840
|
+
};
|
|
841
|
+
await postTuituiMsg(account, payload, 'tuitui.pairing.reply', log);
|
|
842
|
+
}
|
|
843
|
+
} catch (err) {
|
|
844
|
+
log?.warn?.(
|
|
845
|
+
`[${CHANNEL_ID}] pairing flow failed for ${senderForPolicy}: ${String(
|
|
846
|
+
err,
|
|
847
|
+
)}`,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
// Drop unauthorized DM after pairing challenge
|
|
851
|
+
log?.info?.(`[${CHANNEL_ID}] pairing required`);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// dmPolicy=allowlist and sender not allowed
|
|
856
|
+
log?.warn?.(
|
|
857
|
+
`[${CHANNEL_ID}] Blocked unauthorized sender (allowlist): sender=${senderForPolicy} allowFrom=${JSON.stringify(
|
|
858
|
+
normalizedAllowFrom,
|
|
859
|
+
)}`,
|
|
860
|
+
);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// 表情回复。因为回复较慢,所以先回复一个表情
|
|
867
|
+
if (chatType === "direct") {
|
|
868
|
+
await tuituiEmojiReaction(account.appId, account.appSecret, userAccount, false, msg.data.msgid, "收到")
|
|
869
|
+
} else if(chatType === "group") {
|
|
870
|
+
await tuituiEmojiReaction(account.appId, account.appSecret, groupId, true, msg.data.msgid, "收到")
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Build MsgContext
|
|
874
|
+
// 优先使用 userAccount,如果为空则降级使用 userUid
|
|
875
|
+
const senderId = userAccount || userUid || "unknown";
|
|
876
|
+
// 确保 accountId 有有效值,优先使用 account.accountId,其次使用 ctx 中的 accountId,最后使用 DEFAULT_ACCOUNT_ID
|
|
877
|
+
|
|
878
|
+
const effectiveAccountId = String(account.accountId || accountId || DEFAULT_ACCOUNT_ID || 'default');
|
|
879
|
+
const sessionKey = chatType === "group"
|
|
880
|
+
? `${CHANNEL_ID}:${effectiveAccountId}:${groupId}`
|
|
881
|
+
: `${CHANNEL_ID}:${effectiveAccountId}:${senderId}`;
|
|
882
|
+
console.log(`[${CHANNEL_ID}] effectiveAccountId infos:
|
|
883
|
+
account.accountId : ${account.accountId},
|
|
884
|
+
accountId: ${accountId},
|
|
885
|
+
DEFAULT_ACCOUNT_ID: ${DEFAULT_ACCOUNT_ID},
|
|
886
|
+
chatType: ${chatType},
|
|
887
|
+
sessionKey: ${sessionKey},
|
|
888
|
+
effectiveAccountId: ${effectiveAccountId},
|
|
889
|
+
`);
|
|
890
|
+
|
|
891
|
+
const msgCtx: any = {
|
|
892
|
+
Body: text || " ",
|
|
893
|
+
From: String(senderId),
|
|
894
|
+
To: CHANNEL_ID,
|
|
895
|
+
SessionId: String(sessionKey).replace(/\//g, "_"),
|
|
896
|
+
SessionKey: String(sessionKey).replace(/\//g, "_"),
|
|
897
|
+
AccountId: effectiveAccountId,
|
|
898
|
+
OriginatingChannel: CHANNEL_ID,
|
|
899
|
+
OriginatingTo: chatType === "group" ? String(groupId) : String(senderId),
|
|
900
|
+
ChatType: chatType,
|
|
901
|
+
Surface: CHANNEL_ID,
|
|
902
|
+
Provider: CHANNEL_ID,
|
|
903
|
+
SenderName: msg.user_name || String(senderId),
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
// Add group-specific fields
|
|
907
|
+
if (chatType === "group" && groupId) {
|
|
908
|
+
msgCtx.GroupSubject = groupName;
|
|
909
|
+
msgCtx.GroupId = groupId;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Add media URLs if present
|
|
913
|
+
if (mediaUrls?.length) {
|
|
914
|
+
msgCtx.MediaUrls = mediaUrls;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Add reply context if present
|
|
918
|
+
if (replyToId) {
|
|
919
|
+
msgCtx.ReplyToId = replyToId;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Dispatch via the SDK's buffered block dispatcher
|
|
923
|
+
let sentCount = 0;
|
|
924
|
+
if (!apiRuntime) {
|
|
925
|
+
log?.error?.(`[${CHANNEL_ID}] TuiTuiRuntime error,未能发送回复。`);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const currentCfg = await apiRuntime.config.loadConfig();
|
|
929
|
+
await apiRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
930
|
+
ctx: msgCtx,
|
|
931
|
+
cfg: currentCfg,
|
|
932
|
+
dispatcherOptions: {
|
|
933
|
+
deliver: async (payload: {
|
|
934
|
+
text?: string;
|
|
935
|
+
body?: string;
|
|
936
|
+
mediaUrl?: string;
|
|
937
|
+
mediaUrls?: string[];
|
|
938
|
+
custom?: {
|
|
939
|
+
msgtype: string;
|
|
940
|
+
tousers?: string[];
|
|
941
|
+
togroups?: string[];
|
|
942
|
+
[key: string]: any;
|
|
943
|
+
};
|
|
944
|
+
}) => {
|
|
945
|
+
// mark that we received a deliver call (attempt to send reply)
|
|
946
|
+
sentCount++;
|
|
947
|
+
|
|
948
|
+
// 如果设置了 suppressReply 标志,则不发送回复
|
|
949
|
+
if (suppressReply) {
|
|
950
|
+
log?.debug?.(`[${CHANNEL_ID}] Reply suppressed by configuration (shouldReply: false)`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Handle custom messages (e.g., page messages)
|
|
955
|
+
if (payload.custom) {
|
|
956
|
+
try {
|
|
957
|
+
const customPayload = payload.custom;
|
|
958
|
+
|
|
959
|
+
// Handle page message type
|
|
960
|
+
if (customPayload.msgtype === "page" && customPayload.page) {
|
|
961
|
+
const pagePayload: any = {
|
|
962
|
+
tousers: customPayload.tousers ?? (chatType === "direct" && userAccount ? [userAccount] : []),
|
|
963
|
+
togroups: customPayload.togroups ?? (chatType === "group" && groupId ? [groupId] : []),
|
|
964
|
+
msgtype: "page",
|
|
965
|
+
page: customPayload.page,
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
log?.info?.(
|
|
969
|
+
`[${CHANNEL_ID}] sending page reply to TuiTui -
|
|
970
|
+
target_users: [${pagePayload.tousers.join(",")}],
|
|
971
|
+
target_groups: [${pagePayload.togroups.join(",")}]
|
|
972
|
+
chatType=${chatType},
|
|
973
|
+
tousers=${JSON.stringify(pagePayload.tousers)},
|
|
974
|
+
togroups=${JSON.stringify(pagePayload.togroups)},
|
|
975
|
+
title=${String(pagePayload?.page?.title ?? '')}`,
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
await postTuituiMsg(account, pagePayload, 'tuitui.deliver.page', log);
|
|
979
|
+
} else {
|
|
980
|
+
log?.warn?.(`[${CHANNEL_ID}] Unsupported custom message type: ${customPayload.msgtype}`);
|
|
981
|
+
}
|
|
982
|
+
} catch (e) {
|
|
983
|
+
log?.error?.(`[${CHANNEL_ID}] Error sending custom reply to TuiTui: ${e}`);
|
|
984
|
+
}
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (payload.mediaUrl || payload.mediaUrls?.length) {
|
|
989
|
+
// Handle media messages
|
|
990
|
+
const mediaUrl = payload.mediaUrl || payload.mediaUrls?.[0];
|
|
991
|
+
if (mediaUrl) {
|
|
992
|
+
try {
|
|
993
|
+
if (!account.appId || !account.appSecret) {
|
|
994
|
+
log?.error?.(`[${CHANNEL_ID}] Cannot send media: appId and appSecret are required`);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Check if mediaUrl looks like an image
|
|
999
|
+
const isImage = /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(mediaUrl);
|
|
1000
|
+
|
|
1001
|
+
let mediaId: string;
|
|
1002
|
+
if (isImage) {
|
|
1003
|
+
mediaId = await uploadMediaToTuiTui(
|
|
1004
|
+
mediaUrl,
|
|
1005
|
+
account.appId,
|
|
1006
|
+
account.appSecret,
|
|
1007
|
+
"image",
|
|
1008
|
+
);
|
|
1009
|
+
} else {
|
|
1010
|
+
mediaId = await uploadMediaToTuiTui(
|
|
1011
|
+
mediaUrl,
|
|
1012
|
+
account.appId,
|
|
1013
|
+
account.appSecret,
|
|
1014
|
+
"file",
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Build outbound message based on chat type
|
|
1019
|
+
// 群消息:togroups 填群ID,at 是 userAccount 的字符串值,tousers 是空数组
|
|
1020
|
+
// 私聊消息:tousers 填 userAccount,at 不存在,togroups 不存在
|
|
1021
|
+
const outboundMsg: TuiTuiOutboundImageMessage | TuiTuiOutboundAttachmentMessage =
|
|
1022
|
+
isImage
|
|
1023
|
+
? {
|
|
1024
|
+
tousers: chatType === "group" ? [] : userAccount ? [userAccount] : [],
|
|
1025
|
+
togroups: chatType === "group" && groupId ? [groupId] : [],
|
|
1026
|
+
at: chatType === "group" && userAccount ? userAccount : undefined,
|
|
1027
|
+
msgtype: "image",
|
|
1028
|
+
image: {
|
|
1029
|
+
media_id: mediaId,
|
|
1030
|
+
},
|
|
1031
|
+
}
|
|
1032
|
+
: {
|
|
1033
|
+
tousers: chatType === "group" ? [] : userAccount ? [userAccount] : [],
|
|
1034
|
+
togroups: chatType === "group" && groupId ? [groupId] : [],
|
|
1035
|
+
at: chatType === "group" && userAccount ? userAccount : undefined,
|
|
1036
|
+
msgtype: "attachment",
|
|
1037
|
+
attachment: {
|
|
1038
|
+
media_id: mediaId,
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
log?.info?.(
|
|
1043
|
+
`[${CHANNEL_ID}] sending media reply to TuiTui -
|
|
1044
|
+
target_users: [${outboundMsg.tousers.join(",")}],
|
|
1045
|
+
target_groups: [${outboundMsg.togroups.join(",")}],
|
|
1046
|
+
type: ${outboundMsg.msgtype},
|
|
1047
|
+
to=${JSON.stringify(outboundMsg.tousers)},
|
|
1048
|
+
group=${JSON.stringify(outboundMsg.togroups)},
|
|
1049
|
+
msgtype=${outboundMsg.msgtype},
|
|
1050
|
+
chatType=${chatType},
|
|
1051
|
+
userAccount=${userAccount},
|
|
1052
|
+
groupId=${groupId},
|
|
1053
|
+
isImage=${isImage},
|
|
1054
|
+
outboundMsg.tousers=${JSON.stringify(outboundMsg.tousers)},
|
|
1055
|
+
outboundMsg.togroups=${JSON.stringify(outboundMsg.togroups)},
|
|
1056
|
+
outboundMsg.at=${outboundMsg.at}`
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
await postTuituiMsg(account, outboundMsg, 'tuitui.deliver.media', log);
|
|
1060
|
+
} catch (e) {
|
|
1061
|
+
log?.error?.(`[${CHANNEL_ID}] Error sending media reply to TuiTui: ${e}`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
} else if (payload.text || payload.body) {
|
|
1065
|
+
// Handle text messages
|
|
1066
|
+
const replyText = payload?.text ?? payload?.body;
|
|
1067
|
+
if (replyText) {
|
|
1068
|
+
try {
|
|
1069
|
+
// Build outbound message based on chat type
|
|
1070
|
+
// tousers 使用 userAccount(推推用户账号)
|
|
1071
|
+
// at 使用 userAccount(群聊 @ 用户)
|
|
1072
|
+
// 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
|
|
1073
|
+
const outboundMsg: TuiTuiOutboundTextMessage = {
|
|
1074
|
+
tousers: chatType === "direct" && userAccount ? [userAccount] : [],
|
|
1075
|
+
togroups: chatType === "group" && groupId ? [groupId] : [],
|
|
1076
|
+
at: chatType === "group" && userAccount ? [userAccount] : [],
|
|
1077
|
+
msgtype: "text",
|
|
1078
|
+
text: {
|
|
1079
|
+
content: replyText,
|
|
1080
|
+
},
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
log?.info?.(
|
|
1084
|
+
`[${CHANNEL_ID}] sending text reply to TuiTui -
|
|
1085
|
+
target_users: [${outboundMsg.tousers.join(",")}],
|
|
1086
|
+
target_groups: [${outboundMsg.togroups.join(",")}]
|
|
1087
|
+
chatType=${chatType},
|
|
1088
|
+
tousers=${JSON.stringify(outboundMsg.tousers)},
|
|
1089
|
+
togroups=${JSON.stringify(outboundMsg.togroups)},
|
|
1090
|
+
at=${JSON.stringify((outboundMsg as any).at ?? [])},
|
|
1091
|
+
userAccount=${String(userAccount)},
|
|
1092
|
+
groupId=${String(groupId)}`,
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
await postTuituiMsg(account, outboundMsg, 'tuitui.deliver.text', log);
|
|
1096
|
+
} catch (e) {
|
|
1097
|
+
log?.error?.(`[${CHANNEL_ID}] Error sending reply to TuiTui: ${e}`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
onReplyStart: () => {
|
|
1103
|
+
log?.info?.(`[${CHANNEL_ID}] Agent reply started for ${userAccount ?? userUid}`);
|
|
1104
|
+
},
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
const onErrOrClose = () => {
|
|
1110
|
+
_setStatus({ running: false, connected: false });
|
|
1111
|
+
_sendMsg = defSendMsg;
|
|
1112
|
+
if (!abortSignal?.aborted) {
|
|
1113
|
+
startWebSocket(); // 重启
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
ws.on('close', () => {
|
|
1117
|
+
log?.info?.(`[${CHANNEL_ID}] WebSocket closed`);
|
|
1118
|
+
onErrOrClose();
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// on socket errors
|
|
1122
|
+
ws.on('error', (err) => {
|
|
1123
|
+
log?.warn?.(`[${CHANNEL_ID}] WebSocket error: ${err}`);
|
|
1124
|
+
onErrOrClose();
|
|
1125
|
+
});
|
|
1126
|
+
};
|
|
1127
|
+
startWebSocket();
|
|
1128
|
+
|
|
1129
|
+
// Keep the account running until abortSignal is triggered
|
|
1130
|
+
await new Promise<void>((resolve) => {
|
|
1131
|
+
const _onAbort = () => {
|
|
1132
|
+
log?.info?.(`[${CHANNEL_ID}] AccountId ${accountId} stopping (abort signal)`);
|
|
1133
|
+
ws && ws.close();
|
|
1134
|
+
resolve();
|
|
1135
|
+
};
|
|
1136
|
+
if (abortSignal?.aborted) return _onAbort();
|
|
1137
|
+
abortSignal?.addEventListener("abort", _onAbort, { once: true });
|
|
1138
|
+
});
|
|
1139
|
+
},
|
|
1140
|
+
|
|
1141
|
+
stopAccount: async (ctx: any) => {
|
|
1142
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] AccountId ${ctx.accountId} stopped`);
|
|
1143
|
+
ctx.setStatus?.({ ...ctx.getStatus?.(), running: false, connected: false });
|
|
1144
|
+
},
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
1147
|
+
}
|