@qihoo/tuitui-openclaw-channel 1.0.5 → 1.0.7
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 -11
- package/src/channel.ts +161 -420
- package/src/types.ts +16 -22
- package/src/utils.ts +115 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qihoo/tuitui-openclaw-channel",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"maintainers": [
|
|
5
5
|
{
|
|
6
6
|
"name": "huzunjie",
|
|
@@ -25,16 +25,6 @@
|
|
|
25
25
|
"extensions": [
|
|
26
26
|
"./index.ts"
|
|
27
27
|
],
|
|
28
|
-
"channel": {
|
|
29
|
-
"id": "tuitui-openclaw-channel",
|
|
30
|
-
"label": "TuiTui",
|
|
31
|
-
"selectionLabel": "TuiTui",
|
|
32
|
-
"docsPath": "/channels/tuitui",
|
|
33
|
-
"docsLabel": "tuitui",
|
|
34
|
-
"blurb": "TuiTui chat integration.",
|
|
35
|
-
"aliases": [],
|
|
36
|
-
"order": 90
|
|
37
|
-
},
|
|
38
28
|
"install": {
|
|
39
29
|
"npmSpec": "@qihoo/tuitui-openclaw-channel",
|
|
40
30
|
"localPath": "extensions/tuitui",
|
package/src/channel.ts
CHANGED
|
@@ -5,57 +5,101 @@
|
|
|
5
5
|
* Supports single chat (text, image, voice, file) and group chat with @mentions.
|
|
6
6
|
*/
|
|
7
7
|
import WebSocket from 'ws';
|
|
8
|
-
import
|
|
8
|
+
import { z } from 'zod';
|
|
9
9
|
import {
|
|
10
10
|
DEFAULT_ACCOUNT_ID,
|
|
11
11
|
setAccountEnabledInConfigSection,
|
|
12
12
|
deleteAccountFromConfigSection,
|
|
13
|
-
registerPluginHttpRoute,
|
|
14
13
|
buildChannelConfigSchema,
|
|
15
14
|
} from 'openclaw/plugin-sdk';
|
|
16
|
-
import {
|
|
17
|
-
import type {
|
|
18
|
-
TuiTuiInboundMessage,
|
|
19
|
-
TuiTuiOutboundTextMessage,
|
|
20
|
-
TuiTuiOutboundImageMessage,
|
|
21
|
-
TuiTuiOutboundAttachmentMessage,
|
|
22
|
-
} from './types';
|
|
15
|
+
import type { TuiTuiInboundMessage } from './types';
|
|
23
16
|
import {
|
|
24
17
|
CHANNEL_ID,
|
|
25
18
|
CHANNEL_NAME,
|
|
26
|
-
postTuituiMsg,
|
|
27
|
-
uploadFileToTuiTui,
|
|
28
19
|
buildMessageBody,
|
|
29
20
|
tuituiEmojiReaction,
|
|
30
21
|
checkAccount,
|
|
22
|
+
sendTextMsg,
|
|
23
|
+
sendPageMsg,
|
|
24
|
+
sendMediaMsg,
|
|
31
25
|
} from "./utils";
|
|
32
26
|
|
|
33
27
|
function resolveAccount(cfg: any, accountId?: string | null) {
|
|
34
|
-
const channelConfig = cfg?.channels?.[CHANNEL_ID]
|
|
35
|
-
const targetId = accountId
|
|
28
|
+
const channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
|
|
29
|
+
const targetId = accountId || DEFAULT_ACCOUNT_ID;
|
|
36
30
|
const acct = targetId === DEFAULT_ACCOUNT_ID ? channelConfig : channelConfig.accounts?.[targetId];
|
|
37
|
-
|
|
38
31
|
return {
|
|
39
32
|
accountId: targetId,
|
|
40
|
-
enabled: acct?.enabled
|
|
33
|
+
enabled: acct?.enabled || false,
|
|
41
34
|
appId: acct?.appId as string | undefined,
|
|
42
35
|
appSecret: acct?.appSecret as string | undefined,
|
|
43
|
-
dmPolicy: acct?.dmPolicy
|
|
44
|
-
allowFrom: acct?.allowFrom
|
|
36
|
+
dmPolicy: acct?.dmPolicy || 'pairing',
|
|
37
|
+
allowFrom: acct?.allowFrom || [],
|
|
45
38
|
// 群组策略与白名单、群组级覆盖
|
|
46
39
|
groupPolicy: (acct?.groupPolicy as string | undefined) ?? 'allowlist',
|
|
47
|
-
groupAllowFrom:
|
|
40
|
+
groupAllowFrom: acct.groupAllowFrom || [],
|
|
48
41
|
groups: (acct?.groups as Record<string, { requireMention?: boolean, shouldReply?: boolean }> | undefined) ?? {},
|
|
49
42
|
};
|
|
50
43
|
}
|
|
51
44
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
45
|
+
const isConfigured = (account: any)=> Boolean(
|
|
46
|
+
String(account?.appId ?? '').trim() &&
|
|
47
|
+
String(account?.appSecret ?? '').trim()
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const isToGroup = (chatId: string) => /^\d+$/.test(chatId);
|
|
51
|
+
const arrLowerCaseTrim = (arr: any[]) => arr.filter((v: any) => !!v).map((v: any) => String(v).toLowerCase().trim())
|
|
52
|
+
const configSchema = buildChannelConfigSchema(
|
|
53
|
+
z
|
|
54
|
+
.object({
|
|
55
|
+
enabled: z.boolean().optional().describe('开启或关闭'),
|
|
56
|
+
appId: z.string().min(1).describe('推推机器人身份 AppId(你可以推推搜索【推推机器人助手】,和它聊天自助申请推推机器人)'),
|
|
57
|
+
appSecret: z.string().min(1).describe('推推机器人密钥 Secret'),
|
|
58
|
+
// 私聊策略(默认 pairing)
|
|
59
|
+
dmPolicy: z
|
|
60
|
+
.enum(['pairing', 'allowlist', 'open', 'disabled'])
|
|
61
|
+
.optional()
|
|
62
|
+
.default('pairing')
|
|
63
|
+
.describe('私聊策略:pairing=配对(默认);allowlist=白名单;open=允许所有(不安全);disabled=禁用私聊',),
|
|
64
|
+
// 私聊允许列表(当 dmPolicy=allowlist 生效;pairing 下也可显式允许)
|
|
65
|
+
allowFrom: z
|
|
66
|
+
.array(z.string())
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('私聊白名单(dmPolicy=allowlist 时生效;pairing 下可用于显式放行用户)'),
|
|
69
|
+
|
|
70
|
+
// 群组策略
|
|
71
|
+
groupPolicy: z
|
|
72
|
+
.enum(['allowlist', 'disabled'])
|
|
73
|
+
.default('allowlist')
|
|
74
|
+
.describe('群聊策略:allowlist=白名单;disabled=禁用群聊',),
|
|
75
|
+
// 仅在 allowlist 生效的群组 ID 列表
|
|
76
|
+
groupAllowFrom: z
|
|
77
|
+
.array(z.string())
|
|
78
|
+
.optional()
|
|
79
|
+
.describe('群组白名单(仅在 groupPolicy=allowlist 生效)'),
|
|
80
|
+
// 每个群组的覆盖配置
|
|
81
|
+
/*
|
|
82
|
+
groups: z
|
|
83
|
+
.record(
|
|
84
|
+
z.string(),
|
|
85
|
+
z
|
|
86
|
+
.object({
|
|
87
|
+
requireMention: z
|
|
88
|
+
.boolean()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe('该群组是否需要 @ 机器人才触发 Agent(默认 true)'),
|
|
91
|
+
shouldReply: z
|
|
92
|
+
.boolean()
|
|
93
|
+
.optional()
|
|
94
|
+
.describe('该群组是否允许 Agent 回复(默认 true)'),
|
|
95
|
+
})
|
|
96
|
+
.passthrough(),
|
|
97
|
+
)
|
|
98
|
+
.optional()
|
|
99
|
+
.describe('群组级覆盖配置'),
|
|
100
|
+
*/
|
|
101
|
+
}) as any,
|
|
102
|
+
);
|
|
59
103
|
export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
60
104
|
return {
|
|
61
105
|
id: CHANNEL_ID,
|
|
@@ -71,7 +115,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
71
115
|
},
|
|
72
116
|
|
|
73
117
|
capabilities: {
|
|
74
|
-
chatTypes: [
|
|
118
|
+
chatTypes: ['direct' as const, 'group' as const],
|
|
75
119
|
media: true,
|
|
76
120
|
threads: false,
|
|
77
121
|
reactions: false,
|
|
@@ -84,73 +128,19 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
84
128
|
|
|
85
129
|
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
86
130
|
|
|
87
|
-
configSchema
|
|
88
|
-
z
|
|
89
|
-
.object({
|
|
90
|
-
enabled: z.boolean().optional().describe('开启或关闭'),
|
|
91
|
-
appId: z.string().min(1).describe('推推机器人身份 AppId(你可以推推搜索【推推机器人助手】,和它聊天自助申请推推机器人)'),
|
|
92
|
-
appSecret: z.string().min(1).describe('推推机器人密钥 Secret'),
|
|
93
|
-
// 私聊策略(默认 pairing)
|
|
94
|
-
dmPolicy: z
|
|
95
|
-
.enum(['pairing', 'allowlist', 'open', 'disabled'])
|
|
96
|
-
.optional()
|
|
97
|
-
.default('pairing')
|
|
98
|
-
.describe('私聊策略:pairing=配对(默认);allowlist=白名单;open=允许所有(不安全);disabled=禁用私聊',),
|
|
99
|
-
// 私聊允许列表(当 dmPolicy=allowlist 生效;pairing 下也可显式允许)
|
|
100
|
-
allowFrom: z
|
|
101
|
-
.array(z.string())
|
|
102
|
-
.optional()
|
|
103
|
-
.describe('私聊白名单(dmPolicy=allowlist 时生效;pairing 下可用于显式放行用户)'),
|
|
104
|
-
|
|
105
|
-
// 群组策略
|
|
106
|
-
groupPolicy: z
|
|
107
|
-
.enum(['allowlist', 'disabled'])
|
|
108
|
-
.default('allowlist')
|
|
109
|
-
.describe('群聊策略:allowlist=白名单;disabled=禁用群聊',),
|
|
110
|
-
// 仅在 allowlist 生效的群组 ID 列表
|
|
111
|
-
groupAllowFrom: z
|
|
112
|
-
.array(z.string())
|
|
113
|
-
.optional()
|
|
114
|
-
.describe('群组白名单(仅在 groupPolicy=allowlist 生效)'),
|
|
115
|
-
// 每个群组的覆盖配置
|
|
116
|
-
/*
|
|
117
|
-
groups: z
|
|
118
|
-
.record(
|
|
119
|
-
z.string(),
|
|
120
|
-
z
|
|
121
|
-
.object({
|
|
122
|
-
requireMention: z
|
|
123
|
-
.boolean()
|
|
124
|
-
.optional()
|
|
125
|
-
.describe('该群组是否需要 @ 机器人才触发 Agent(默认 true)'),
|
|
126
|
-
shouldReply: z
|
|
127
|
-
.boolean()
|
|
128
|
-
.optional()
|
|
129
|
-
.describe('该群组是否允许 Agent 回复(默认 true)'),
|
|
130
|
-
})
|
|
131
|
-
.passthrough(),
|
|
132
|
-
)
|
|
133
|
-
.optional()
|
|
134
|
-
.describe('群组级覆盖配置'),
|
|
135
|
-
*/
|
|
136
|
-
}) as any,
|
|
137
|
-
),
|
|
131
|
+
configSchema,
|
|
138
132
|
|
|
139
133
|
config: {
|
|
140
134
|
listAccountIds: (cfg: any) => {
|
|
141
135
|
const base = cfg?.channels?.[CHANNEL_ID];
|
|
142
136
|
if (!base) return [];
|
|
143
137
|
const ids = new Set<string>();
|
|
144
|
-
if (base.enabled
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
if (base.accounts) {
|
|
148
|
-
for (const k in base.accounts) ids.add(k);
|
|
149
|
-
}
|
|
138
|
+
if (base.enabled) ids.add(DEFAULT_ACCOUNT_ID);
|
|
139
|
+
if (base.accounts) for (const k in base.accounts) ids.add(k);
|
|
150
140
|
return Array.from(ids);
|
|
151
141
|
},
|
|
152
142
|
|
|
153
|
-
resolveAccount
|
|
143
|
+
resolveAccount,
|
|
154
144
|
|
|
155
145
|
defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID,
|
|
156
146
|
|
|
@@ -181,10 +171,10 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
181
171
|
},
|
|
182
172
|
|
|
183
173
|
deleteAccount: ({ cfg, accountId }: any) => {
|
|
184
|
-
|
|
174
|
+
return accountId === DEFAULT_ACCOUNT_ID
|
|
185
175
|
// For default account, we don't delete the entire config
|
|
186
176
|
// Instead, we disable it and clear sensitive fields
|
|
187
|
-
|
|
177
|
+
? {
|
|
188
178
|
...cfg,
|
|
189
179
|
channels: {
|
|
190
180
|
...cfg.channels,
|
|
@@ -195,23 +185,22 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
195
185
|
appSecret: undefined,
|
|
196
186
|
},
|
|
197
187
|
},
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
});
|
|
188
|
+
}
|
|
189
|
+
// For named accounts, use the standard delete function
|
|
190
|
+
: deleteAccountFromConfigSection({
|
|
191
|
+
cfg,
|
|
192
|
+
sectionKey: CHANNEL_ID,
|
|
193
|
+
accountId,
|
|
194
|
+
clearBaseFields: [
|
|
195
|
+
'appId',
|
|
196
|
+
'appSecret',
|
|
197
|
+
'dmPolicy',
|
|
198
|
+
'allowFrom',
|
|
199
|
+
'groupPolicy',
|
|
200
|
+
'groupAllowFrom',
|
|
201
|
+
'groups',
|
|
202
|
+
],
|
|
203
|
+
});
|
|
215
204
|
},
|
|
216
205
|
},
|
|
217
206
|
|
|
@@ -234,12 +223,12 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
234
223
|
|
|
235
224
|
security: {
|
|
236
225
|
resolveDmPolicy: ({ cfg, accountId, account }: { cfg: any; accountId?: string | null; account?: any; }) => {
|
|
237
|
-
|
|
238
|
-
const
|
|
226
|
+
account = account || resolveAccount(cfg, accountId);
|
|
227
|
+
const accId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
239
228
|
let allowFromPath =`channels.${CHANNEL_ID}.`;
|
|
240
|
-
if (
|
|
229
|
+
if (accId !== DEFAULT_ACCOUNT_ID) allowFromPath += `accounts.${accId}.`;
|
|
241
230
|
|
|
242
|
-
const policy =
|
|
231
|
+
const policy = account.dmPolicy ?? 'pairing';
|
|
243
232
|
// dmPolicy semantics:
|
|
244
233
|
// - open: always allow everyone (["*"]), ignore allowFrom values.
|
|
245
234
|
// - pairing: unknown senders get a pairing code; approvals add to allowFrom store.
|
|
@@ -247,7 +236,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
247
236
|
// - disabled: block all DMs.
|
|
248
237
|
return {
|
|
249
238
|
policy,
|
|
250
|
-
allowFrom: policy === 'open' ? ['*'] : (
|
|
239
|
+
allowFrom: policy === 'open' ? ['*'] : (account.allowFrom ?? []),
|
|
251
240
|
policyPath: `${allowFromPath}dmPolicy`,
|
|
252
241
|
allowFromPath,
|
|
253
242
|
approveHint: `openclaw pairing approve ${CHANNEL_ID} <code>`,
|
|
@@ -274,144 +263,45 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
274
263
|
deliveryMode: 'gateway' as const,
|
|
275
264
|
textChunkLimit: 2000,
|
|
276
265
|
|
|
277
|
-
sendText: async ({ cfg, to, text, accountId, account
|
|
278
|
-
|
|
279
|
-
checkAccount(
|
|
266
|
+
sendText: async ({ cfg, to, text, accountId, account }: any) => {
|
|
267
|
+
account = account || resolveAccount(cfg, accountId);
|
|
268
|
+
checkAccount(account, 'send text');
|
|
280
269
|
|
|
281
|
-
const
|
|
270
|
+
const chatId = String(to || '').trim();
|
|
282
271
|
// Determine if this is a group message based on 'to' being all digits (group) or not (direct)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
// Format message according to TuiTui's required structure
|
|
286
|
-
const payload: TuiTuiOutboundTextMessage = {
|
|
287
|
-
tousers: isGroup ? [] : [toStr],
|
|
288
|
-
togroups: isGroup ? [toStr] : [],
|
|
289
|
-
at: [],
|
|
290
|
-
msgtype: 'text',
|
|
291
|
-
text: { content: text },
|
|
292
|
-
};
|
|
272
|
+
await sendTextMsg(account, chatId, isToGroup(chatId), text);
|
|
293
273
|
|
|
294
|
-
|
|
295
|
-
`[${CHANNEL_ID}] sendText -
|
|
296
|
-
accountId=${accountId ?? 'default'},
|
|
297
|
-
to=${toStr},
|
|
298
|
-
target_users: [${payload.tousers.join(",")}],
|
|
299
|
-
target_groups: [${payload.togroups.join(",")}],
|
|
300
|
-
isGroup: ${isGroup},
|
|
301
|
-
textLength=${String(text ?? '').length},
|
|
302
|
-
text: ${text}`
|
|
303
|
-
);
|
|
304
|
-
|
|
305
|
-
await postTuituiMsg(_account, payload, 'tuitui.send.text', log);
|
|
306
|
-
|
|
307
|
-
return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: toStr };
|
|
274
|
+
return { channel: CHANNEL_ID, messageId: `tuitui-text-${Date.now()}`, chatId };
|
|
308
275
|
},
|
|
309
276
|
|
|
310
|
-
sendCustom: async ({ cfg, to, payload, accountId, account, chatType, groupId
|
|
311
|
-
|
|
312
|
-
checkAccount(
|
|
277
|
+
sendCustom: async ({ cfg, to, payload, accountId, account, chatType, groupId }: any) => {
|
|
278
|
+
account = account || resolveAccount(cfg, accountId);
|
|
279
|
+
checkAccount(account, 'send custom message');
|
|
313
280
|
|
|
314
281
|
// If it's a page message, we need to construct it
|
|
315
282
|
if (payload?.msgtype !== 'page') {
|
|
316
283
|
throw new Error(`[${CHANNEL_ID}] unsupported custom message type: ${payload?.msgtype}`);
|
|
317
284
|
}
|
|
318
285
|
|
|
319
|
-
const
|
|
286
|
+
const chatId = String(to || '').trim();
|
|
320
287
|
// Determine if this is a group message
|
|
321
288
|
// WORKAROUND: OpenClaw core doesn't pass chatType/groupId to sendPayload.
|
|
322
289
|
// If `to` is a numeric string and chatType/groupId are undefined, assume it's a group.
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
const toGroupId = groupId || (isGroup ? toStr : undefined);
|
|
326
|
-
const toUserId = isGroup ? undefined : toStr;
|
|
327
|
-
|
|
328
|
-
const _payload: any = {
|
|
329
|
-
tousers: payload.tousers || (toUserId ? [toUserId] : []),
|
|
330
|
-
togroups: payload.togroups || (toGroupId ? [toGroupId] : []),
|
|
331
|
-
msgtype: 'page',
|
|
332
|
-
page: { ...payload.page }
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
log?.info?.(
|
|
336
|
-
`[${CHANNEL_ID}] sending page to TuiTui -
|
|
337
|
-
target_users: [${_payload.tousers.join(",")}],
|
|
338
|
-
target_groups: [${_payload.togroups.join(",")}],
|
|
339
|
-
chatType: ${chatType ?? 'direct'},
|
|
340
|
-
account=${accountId ?? 'default'},
|
|
341
|
-
chatType=${chatType ?? 'direct'},
|
|
342
|
-
to=${toStr},
|
|
343
|
-
groupId=${groupId ?? '-'}`,
|
|
344
|
-
);
|
|
345
|
-
|
|
346
|
-
await postTuituiMsg(_account, _payload, 'tuitui.send.page', log);
|
|
290
|
+
const isGroup = chatType === 'group' || !!groupId || (isToGroup(chatId) && !chatType);
|
|
291
|
+
await sendPageMsg(account, chatId, isGroup, payload.page);
|
|
347
292
|
|
|
348
|
-
return { channel: CHANNEL_ID, messageId: `
|
|
293
|
+
return { channel: CHANNEL_ID, messageId: `tuitui-page-${Date.now()}`, chatId };
|
|
349
294
|
},
|
|
350
295
|
|
|
351
|
-
sendMedia: async ({ cfg, to, mediaUrl, accountId, account
|
|
352
|
-
|
|
353
|
-
checkAccount(
|
|
296
|
+
sendMedia: async ({ cfg, to, payload, mediaUrl, accountId, account }: any) => {
|
|
297
|
+
account = account || resolveAccount(cfg, accountId);
|
|
298
|
+
checkAccount(account, 'send media');
|
|
354
299
|
|
|
355
|
-
const
|
|
300
|
+
const chatId = String(to || '').trim();
|
|
356
301
|
// Determine if this is a group message based on 'to' being all digits (group) or not (direct)
|
|
357
|
-
|
|
358
|
-
const toGroupId = isGroup ? toStr : undefined;
|
|
359
|
-
const toUserId = isGroup ? undefined : toStr;
|
|
360
|
-
|
|
361
|
-
// Detect image/file by URL pattern first; fallback to upload API type auto-detection when needed.
|
|
362
|
-
// NOTE: relying on `mediaUrl.includes("image")` is too broad and can misclassify files.
|
|
363
|
-
const isImageByExt = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(?:$|[?#])/i.test(mediaUrl);
|
|
364
|
-
const isDataUrlImage = /^data:image\//i.test(mediaUrl);
|
|
365
|
-
const isImage = isImageByExt || isDataUrlImage;
|
|
366
|
-
const uploadType = isImage ? 'image' : 'file';
|
|
367
|
-
|
|
368
|
-
log?.debug?.(
|
|
369
|
-
`[${CHANNEL_ID}] sendMedia classify
|
|
370
|
-
account=${accountId ?? 'default'},
|
|
371
|
-
to=${toStr},
|
|
372
|
-
isGroup=${isGroup},
|
|
373
|
-
mediaUrl=${mediaUrl},
|
|
374
|
-
isImageByExt=${isImageByExt},
|
|
375
|
-
isDataUrlImage=${isDataUrlImage},
|
|
376
|
-
uploadType=${uploadType}`,
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
const media_id = await uploadFileToTuiTui(mediaUrl, _account, uploadType);
|
|
380
|
-
if (!media_id) {
|
|
381
|
-
log?.error?.(`[${CHANNEL_ID}] sendMedia upload failed to get media_id from TuiTui for mediaUrl: ${mediaUrl}`);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
log?.debug?.(`[${CHANNEL_ID}] sendMedia upload done type=${uploadType} media_id=${media_id}`);
|
|
385
|
-
|
|
386
|
-
// Keep outbound msgtype consistent with upload type.
|
|
387
|
-
// 群消息:togroups 填群ID,tousers 是空数组;私聊:tousers 填 to;均不设置 at
|
|
388
|
-
const payload: any = {
|
|
389
|
-
tousers: toUserId ? [toUserId] : [],
|
|
390
|
-
togroups: toGroupId ? [toGroupId] : [],
|
|
391
|
-
msgtype: uploadType === 'image' ? 'image' : 'attachment',
|
|
392
|
-
image: { media_id },
|
|
393
|
-
};
|
|
302
|
+
await sendMediaMsg(account, chatId, isToGroup(chatId), mediaUrl, 'tuitui.send.media');
|
|
394
303
|
|
|
395
|
-
|
|
396
|
-
`[${CHANNEL_ID}] sending media to TuiTui -
|
|
397
|
-
target_users: [${payload.tousers.join(',')}],
|
|
398
|
-
target_groups: [${payload.togroups.join(',')}],
|
|
399
|
-
type: ${payload.msgtype},
|
|
400
|
-
isGroup=${isGroup},
|
|
401
|
-
msgtype=${payload.msgtype},
|
|
402
|
-
account=${accountId ?? 'default'},
|
|
403
|
-
to=${toStr},
|
|
404
|
-
isGroup=${isGroup},
|
|
405
|
-
payload=${JSON.stringify(payload)},
|
|
406
|
-
toGroupId=${toGroupId},
|
|
407
|
-
toUserId=${toUserId},
|
|
408
|
-
uploadType=${uploadType},
|
|
409
|
-
media_id=${media_id}`
|
|
410
|
-
);
|
|
411
|
-
|
|
412
|
-
await postTuituiMsg(_account, payload, 'tuitui.send.media', log);
|
|
413
|
-
|
|
414
|
-
return { channel: CHANNEL_ID, messageId: `tt-${Date.now()}`, chatId: toStr };
|
|
304
|
+
return { channel: CHANNEL_ID, messageId: `tuitui-media-${Date.now()}`, chatId };
|
|
415
305
|
},
|
|
416
306
|
},
|
|
417
307
|
|
|
@@ -497,8 +387,8 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
497
387
|
const msg = json.body as TuiTuiInboundMessage;
|
|
498
388
|
const msgData = msg.data;
|
|
499
389
|
let userAccount: string | undefined = msg.user_account;
|
|
500
|
-
let
|
|
501
|
-
let
|
|
390
|
+
let msgUid: string | undefined = msg.uid;
|
|
391
|
+
let msgUname: string | undefined = msg.user_name;
|
|
502
392
|
let chatType: 'direct' | 'group';
|
|
503
393
|
const chatTypeIsDirect = wsEvent === 'single_chat';
|
|
504
394
|
const chatTypeIsGroup = wsEvent === 'group_chat';
|
|
@@ -515,9 +405,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
515
405
|
chatType = 'direct';
|
|
516
406
|
text = buildMessageBody(msgData);
|
|
517
407
|
|
|
518
|
-
log?.debug?.(
|
|
519
|
-
`[${CHANNEL_ID}] inbound single_chat user_account=${String(userAccount)} uid=${String(userUid)} user_name=${String(msg.user_name)}`,
|
|
520
|
-
);
|
|
408
|
+
log?.debug?.(`[${CHANNEL_ID}] inbound single_chat user_account=${userAccount} uid=${msgUid} user_name=${msgUname}`);
|
|
521
409
|
|
|
522
410
|
const msgType = msgData.msg_type;
|
|
523
411
|
// Extract media URLs for image/voice/file messages
|
|
@@ -534,7 +422,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
534
422
|
replyToId = msgData.ref.msgid;
|
|
535
423
|
}
|
|
536
424
|
|
|
537
|
-
if (!userAccount && !
|
|
425
|
+
if (!userAccount && !msgUid) {
|
|
538
426
|
log?.info?.(`[${CHANNEL_ID}] Missing user_account or uid in single_chat event`);
|
|
539
427
|
return;
|
|
540
428
|
}
|
|
@@ -544,27 +432,16 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
544
432
|
groupId = msgData.group_id;
|
|
545
433
|
groupName = msgData.group_name;
|
|
546
434
|
|
|
547
|
-
log?.debug?.(
|
|
548
|
-
`[${CHANNEL_ID}] inbound group_chat user_account=${String(userAccount)} uid=${String(userUid)} user_name=${String(msg.user_name)} group_id=${String(groupId)}`,
|
|
549
|
-
);
|
|
435
|
+
log?.debug?.(`[${CHANNEL_ID}] inbound group_chat user_account=${userAccount} uid=${msgUid} user_name=${msgUname} group_id=${groupId}`);
|
|
550
436
|
|
|
551
437
|
// Group policy gating and @mention requirements
|
|
552
438
|
const groupPolicy = String(account.groupPolicy ?? "allowlist").toLowerCase();
|
|
553
439
|
const groupAllowFromRaw = Array.isArray(account.groupAllowFrom) ? account.groupAllowFrom : [];
|
|
554
|
-
const normalizedGroupAllowFrom = groupAllowFromRaw
|
|
555
|
-
|
|
556
|
-
.map((v: unknown) => String(v).trim())
|
|
557
|
-
.filter(Boolean);
|
|
558
|
-
log?.debug?.(
|
|
559
|
-
`[${CHANNEL_ID}] groupPolicy=${groupPolicy} groupId=${String(groupId)} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`,
|
|
560
|
-
);
|
|
561
|
-
|
|
562
|
-
if (groupPolicy === 'disabled') {
|
|
563
|
-
log?.info?.('Groups disabled');
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
|
|
440
|
+
const normalizedGroupAllowFrom = arrLowerCaseTrim(groupAllowFromRaw);
|
|
441
|
+
log?.debug?.(`[${CHANNEL_ID}] groupPolicy=${groupPolicy} groupId=${groupId} groupAllowFrom=${JSON.stringify(normalizedGroupAllowFrom)} at_me=${JSON.stringify(msgData.at_me)} at=${JSON.stringify(msgData.at)}`);
|
|
567
442
|
|
|
443
|
+
if (groupPolicy === 'disabled') return log?.info?.('Groups disabled');
|
|
444
|
+
|
|
568
445
|
// 群消息处理策略
|
|
569
446
|
const groupCfg = account.groups?.[String(groupId)];
|
|
570
447
|
|
|
@@ -610,16 +487,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
610
487
|
}
|
|
611
488
|
|
|
612
489
|
if (!normalizedGroupAllowFrom.includes(String(groupId))) {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const payload: TuiTuiOutboundTextMessage = {
|
|
616
|
-
tousers: [],
|
|
617
|
-
togroups: [groupId],
|
|
618
|
-
at: [],
|
|
619
|
-
msgtype: "text",
|
|
620
|
-
text: { content: `当前openclaw群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${groupId}` },
|
|
621
|
-
};
|
|
622
|
-
await postTuituiMsg(account, payload, 'tuitui.groupPolicy.reply', log);
|
|
490
|
+
await sendTextMsg(account, groupId, true, `当前openclaw群聊策略为白名单,需要主人在群白名单(Group Allow From)增加当前群ID:\n${groupId}`, 'tuitui.groupPolicy.reply');
|
|
623
491
|
return;
|
|
624
492
|
}
|
|
625
493
|
|
|
@@ -635,14 +503,10 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
635
503
|
// DM access gating (pairing/allowlist/open/disabled)
|
|
636
504
|
const dmPolicy = String(account.dmPolicy ?? 'pairing').toLowerCase();
|
|
637
505
|
const configuredAllowFrom = Array.isArray(account.allowFrom) ? account.allowFrom : [];
|
|
638
|
-
const normalizedAllowFrom = configuredAllowFrom
|
|
639
|
-
.filter((v: unknown) => !!v)
|
|
640
|
-
.map((v: unknown) => String(v).toLowerCase().trim());
|
|
506
|
+
const normalizedAllowFrom = arrLowerCaseTrim(configuredAllowFrom);
|
|
641
507
|
// 只使用 userAccount 作为匹配依据,因为用户希望 allowFrom 匹配 user_account
|
|
642
508
|
const senderForPolicy = userAccount ? String(userAccount).toLowerCase().trim() : '';
|
|
643
|
-
log?.debug?.(
|
|
644
|
-
`[${CHANNEL_ID}] dmPolicy=${dmPolicy} userAccount=${String(userAccount)} userUid=${String(userUid)} senderForPolicy=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
|
|
645
|
-
);
|
|
509
|
+
log?.debug?.(`[${CHANNEL_ID}] dmPolicy=${dmPolicy} userAccount=${userAccount} msgUid=${msgUid} senderForPolicy=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`);
|
|
646
510
|
|
|
647
511
|
if (chatTypeIsDirect) {
|
|
648
512
|
if (dmPolicy === 'disabled') {
|
|
@@ -653,20 +517,14 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
653
517
|
if (dmPolicy !== 'open') {
|
|
654
518
|
// Merge pairing-store entries unless policy is allowlist-only
|
|
655
519
|
let storeAllowFrom: string[] = [];
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
const
|
|
520
|
+
if (dmPolicy !== 'allowlist') {
|
|
521
|
+
try {
|
|
522
|
+
const res = await apiRuntime?.channel?.pairing?.readAllowFromStore?.({
|
|
659
523
|
channel: CHANNEL_ID,
|
|
660
524
|
accountId: account.accountId,
|
|
661
525
|
});
|
|
662
|
-
if (Array.isArray(
|
|
663
|
-
|
|
664
|
-
.filter((v: unknown) => !!v)
|
|
665
|
-
.map((v: unknown) => String(v).toLowerCase().trim());
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
} catch {
|
|
669
|
-
storeAllowFrom = [];
|
|
526
|
+
if (Array.isArray(res)) storeAllowFrom = arrLowerCaseTrim(res);
|
|
527
|
+
} catch {}
|
|
670
528
|
}
|
|
671
529
|
|
|
672
530
|
// 只检查 userAccount 是否在 allowFrom 或 storeAllowFrom 中
|
|
@@ -676,9 +534,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
676
534
|
if (!isAllowed) {
|
|
677
535
|
if (dmPolicy === 'pairing') {
|
|
678
536
|
try {
|
|
679
|
-
log?.debug?.(
|
|
680
|
-
`[${CHANNEL_ID}] pairing flow: checking if pairing request exists for sender=${senderForPolicy}`,
|
|
681
|
-
);
|
|
537
|
+
log?.debug?.(`[${CHANNEL_ID}] pairing flow: checking if pairing request exists for sender=${senderForPolicy}`);
|
|
682
538
|
const req = await apiRuntime?.channel?.pairing?.upsertPairingRequest?.({
|
|
683
539
|
channel: CHANNEL_ID,
|
|
684
540
|
accountId: account.accountId,
|
|
@@ -696,21 +552,11 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
696
552
|
apiRuntime?.channel?.pairing?.buildPairingReply?.({
|
|
697
553
|
channel: CHANNEL_ID,
|
|
698
554
|
code: req.code,
|
|
699
|
-
}) ?? '
|
|
555
|
+
}) ?? '需要进行配对。请让机器人所有者进行批准。';
|
|
700
556
|
|
|
701
557
|
// tousers 使用 userAccount(推推用户账号)
|
|
702
558
|
const toUname = String(userAccount || '').trim();
|
|
703
|
-
|
|
704
|
-
`[${CHANNEL_ID}] pairing.reply target=${toUname}`,
|
|
705
|
-
);
|
|
706
|
-
const payload: TuiTuiOutboundTextMessage = {
|
|
707
|
-
tousers: toUname ? [toUname] : [],
|
|
708
|
-
togroups: [],
|
|
709
|
-
at: [],
|
|
710
|
-
msgtype: 'text',
|
|
711
|
-
text: { content: replyText },
|
|
712
|
-
};
|
|
713
|
-
await postTuituiMsg(account, payload, 'tuitui.pairing.reply', log);
|
|
559
|
+
await sendTextMsg(account, toUname, false, replyText, 'tuitui.pairing.reply');
|
|
714
560
|
}
|
|
715
561
|
} catch (err) {
|
|
716
562
|
log?.warn?.(`[${CHANNEL_ID}] pairing flow failed for ${senderForPolicy}: ${String(err)}` );
|
|
@@ -721,9 +567,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
721
567
|
}
|
|
722
568
|
|
|
723
569
|
// dmPolicy=allowlist and sender not allowed
|
|
724
|
-
log?.warn?.(
|
|
725
|
-
`[${CHANNEL_ID}] Blocked unauthorized sender (allowlist): sender=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`,
|
|
726
|
-
);
|
|
570
|
+
log?.warn?.(`[${CHANNEL_ID}] Blocked unauthorized sender (allowlist): sender=${senderForPolicy} allowFrom=${JSON.stringify(normalizedAllowFrom)}`);
|
|
727
571
|
return;
|
|
728
572
|
}
|
|
729
573
|
}
|
|
@@ -733,22 +577,13 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
733
577
|
await tuituiEmojiReaction(account, chatTypeIsGroup ? groupId : userAccount, chatTypeIsGroup, msgData.msgid, '收到');
|
|
734
578
|
|
|
735
579
|
// Build MsgContext
|
|
736
|
-
// 优先使用 userAccount,如果为空则降级使用
|
|
737
|
-
const senderId = userAccount ||
|
|
580
|
+
// 优先使用 userAccount,如果为空则降级使用 msgUid
|
|
581
|
+
const senderId = userAccount || msgUid || 'unknown';
|
|
738
582
|
// 确保 accountId 有有效值,优先使用 account.accountId,其次使用 ctx 中的 accountId,最后使用 DEFAULT_ACCOUNT_ID
|
|
739
583
|
|
|
740
584
|
const effectiveAccountId = String(account.accountId || accountId || DEFAULT_ACCOUNT_ID || 'default');
|
|
741
|
-
const sessionKey = chatTypeIsGroup
|
|
742
|
-
|
|
743
|
-
: `${CHANNEL_ID}:${effectiveAccountId}:${senderId}`;
|
|
744
|
-
console.log(`[${CHANNEL_ID}] effectiveAccountId infos:
|
|
745
|
-
account.accountId : ${account.accountId},
|
|
746
|
-
accountId: ${accountId},
|
|
747
|
-
DEFAULT_ACCOUNT_ID: ${DEFAULT_ACCOUNT_ID},
|
|
748
|
-
chatType: ${chatType},
|
|
749
|
-
sessionKey: ${sessionKey},
|
|
750
|
-
effectiveAccountId: ${effectiveAccountId},
|
|
751
|
-
`);
|
|
585
|
+
const sessionKey = `${CHANNEL_ID}:${effectiveAccountId}:${chatTypeIsGroup ? groupId : userAccount}`;
|
|
586
|
+
console.log(`[${CHANNEL_ID}] effectiveAccountId infos: account.accountId : ${account.accountId}, accountId: ${accountId}, DEFAULT_ACCOUNT_ID: ${DEFAULT_ACCOUNT_ID}, chatType: ${chatType}, sessionKey: ${sessionKey}, effectiveAccountId: ${effectiveAccountId}`);
|
|
752
587
|
|
|
753
588
|
const msgCtx: any = {
|
|
754
589
|
Body: text || ' ',
|
|
@@ -762,7 +597,7 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
762
597
|
ChatType: chatType,
|
|
763
598
|
Surface: CHANNEL_ID,
|
|
764
599
|
Provider: CHANNEL_ID,
|
|
765
|
-
SenderName:
|
|
600
|
+
SenderName: msgUname || String(senderId),
|
|
766
601
|
};
|
|
767
602
|
|
|
768
603
|
// Add group-specific fields
|
|
@@ -778,7 +613,6 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
778
613
|
if (replyToId) msgCtx.ReplyToId = replyToId;
|
|
779
614
|
|
|
780
615
|
// Dispatch via the SDK's buffered block dispatcher
|
|
781
|
-
let sentCount = 0;
|
|
782
616
|
if (!apiRuntime) {
|
|
783
617
|
log?.error?.(`[${CHANNEL_ID}] TuiTuiRuntime error,未能发送回复。`);
|
|
784
618
|
return;
|
|
@@ -800,8 +634,6 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
800
634
|
[key: string]: any;
|
|
801
635
|
};
|
|
802
636
|
}) => {
|
|
803
|
-
// mark that we received a deliver call (attempt to send reply)
|
|
804
|
-
sentCount++;
|
|
805
637
|
|
|
806
638
|
// 如果设置了 suppressReply 标志,则不发送回复
|
|
807
639
|
if (suppressReply) {
|
|
@@ -809,129 +641,38 @@ export function createTuiTuiChannelPlugin(apiRuntime: any) {
|
|
|
809
641
|
return;
|
|
810
642
|
}
|
|
811
643
|
|
|
644
|
+
const chatTarget = chatTypeIsGroup ? groupId : userAccount;
|
|
812
645
|
// Handle custom messages (e.g., page messages)
|
|
813
646
|
if (payload.custom) {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const pagePayload: any = {
|
|
820
|
-
tousers: customPayload.tousers || (chatTypeIsDirect && userAccount ? [userAccount] : []),
|
|
821
|
-
togroups: customPayload.togroups || (chatTypeIsGroup && groupId ? [groupId] : []),
|
|
822
|
-
msgtype: 'page',
|
|
823
|
-
page: customPayload.page,
|
|
824
|
-
};
|
|
825
|
-
|
|
826
|
-
log?.info?.(
|
|
827
|
-
`[${CHANNEL_ID}] sending page reply to TuiTui -
|
|
828
|
-
target_users: [${pagePayload.tousers.join(',')}],
|
|
829
|
-
target_groups: [${pagePayload.togroups.join(',')}]
|
|
830
|
-
chatType=${chatType},
|
|
831
|
-
tousers=${JSON.stringify(pagePayload.tousers)},
|
|
832
|
-
togroups=${JSON.stringify(pagePayload.togroups)},
|
|
833
|
-
title=${String(pagePayload?.page?.title ?? '')}`,
|
|
834
|
-
);
|
|
835
|
-
|
|
836
|
-
await postTuituiMsg(account, pagePayload, 'tuitui.deliver.page', log);
|
|
837
|
-
} else {
|
|
838
|
-
log?.warn?.(`[${CHANNEL_ID}] Unsupported custom message type: ${customPayload.msgtype}`);
|
|
839
|
-
}
|
|
840
|
-
} catch (e) {
|
|
841
|
-
log?.error?.(`[${CHANNEL_ID}] Error sending custom reply to TuiTui: ${e}`);
|
|
647
|
+
const { msgtype, page, tousers, togroups } = payload.custom;
|
|
648
|
+
// Handle page message type
|
|
649
|
+
if (msgtype === 'page' && page) {
|
|
650
|
+
await sendPageMsg(account, chatTarget, chatTypeIsGroup, page, 'tuitui.deliver.page', tousers, togroups);
|
|
651
|
+
return;
|
|
842
652
|
}
|
|
653
|
+
log?.warn?.(`[${CHANNEL_ID}] Unsupported custom message type: ${msgtype}`);
|
|
843
654
|
return;
|
|
844
655
|
}
|
|
845
|
-
|
|
846
656
|
if (payload.mediaUrl || payload.mediaUrls?.length) {
|
|
847
657
|
// Handle media messages
|
|
848
658
|
const mediaUrl = payload.mediaUrl || payload.mediaUrls?.[0];
|
|
849
659
|
if (mediaUrl) {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
// Check if mediaUrl looks like an image
|
|
854
|
-
const isImage = /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(mediaUrl);
|
|
855
|
-
|
|
856
|
-
const media_id = await uploadFileToTuiTui(mediaUrl, account, isImage ? 'image' : 'file');
|
|
857
|
-
if (!media_id) {
|
|
858
|
-
log?.error?.(`[${CHANNEL_ID}] sendMedia upload failed to get media_id from TuiTui for mediaUrl: ${mediaUrl}`);
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// Build outbound message based on chat type
|
|
863
|
-
// 群消息:togroups 填群ID,at 是 userAccount 的字符串值,tousers 是空数组
|
|
864
|
-
// 私聊消息:tousers 填 userAccount,at 不存在,togroups 不存在
|
|
865
|
-
const _baseOutboundMsg = {
|
|
866
|
-
tousers: chatTypeIsGroup ? [] : userAccount ? [userAccount] : [],
|
|
867
|
-
togroups: chatTypeIsGroup && groupId ? [groupId] : [],
|
|
868
|
-
at: chatTypeIsGroup && userAccount ? userAccount : undefined,
|
|
869
|
-
};
|
|
870
|
-
const outboundMsg: TuiTuiOutboundImageMessage | TuiTuiOutboundAttachmentMessage =
|
|
871
|
-
isImage
|
|
872
|
-
? { ..._baseOutboundMsg, msgtype: 'image', image: { media_id } }
|
|
873
|
-
: { ..._baseOutboundMsg, msgtype: 'attachment', attachment: { media_id } };
|
|
874
|
-
|
|
875
|
-
log?.info?.(
|
|
876
|
-
`[${CHANNEL_ID}] sending media reply to TuiTui -
|
|
877
|
-
target_users: [${outboundMsg?.tousers?.join(",")}],
|
|
878
|
-
target_groups: [${outboundMsg?.togroups?.join(",")}],
|
|
879
|
-
type: ${outboundMsg.msgtype},
|
|
880
|
-
to=${JSON.stringify(outboundMsg.tousers)},
|
|
881
|
-
group=${JSON.stringify(outboundMsg.togroups)},
|
|
882
|
-
msgtype=${outboundMsg.msgtype},
|
|
883
|
-
chatType=${chatType},
|
|
884
|
-
userAccount=${userAccount},
|
|
885
|
-
groupId=${groupId},
|
|
886
|
-
isImage=${isImage},
|
|
887
|
-
outboundMsg.tousers=${JSON.stringify(outboundMsg.tousers)},
|
|
888
|
-
outboundMsg.togroups=${JSON.stringify(outboundMsg.togroups)},
|
|
889
|
-
outboundMsg.at=${outboundMsg.at}`
|
|
890
|
-
);
|
|
891
|
-
|
|
892
|
-
await postTuituiMsg(account, outboundMsg, 'tuitui.deliver.media', log);
|
|
893
|
-
} catch (e) {
|
|
894
|
-
log?.error?.(`[${CHANNEL_ID}] Error sending media reply to TuiTui: ${e}`);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
} else if (payload.text || payload.body) {
|
|
898
|
-
// Handle text messages
|
|
899
|
-
const replyText = payload?.text ?? payload?.body;
|
|
900
|
-
if (replyText) {
|
|
901
|
-
try {
|
|
902
|
-
// Build outbound message based on chat type
|
|
903
|
-
// tousers 使用 userAccount(推推用户账号)
|
|
904
|
-
// at 使用 userAccount(群聊 @ 用户)
|
|
905
|
-
// 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
|
|
906
|
-
const outboundMsg: TuiTuiOutboundTextMessage = {
|
|
907
|
-
tousers: chatTypeIsDirect && userAccount ? [userAccount] : [],
|
|
908
|
-
togroups: chatTypeIsGroup && groupId ? [groupId] : [],
|
|
909
|
-
at: chatTypeIsGroup && userAccount ? [userAccount] : [],
|
|
910
|
-
msgtype: 'text',
|
|
911
|
-
text: { content: replyText },
|
|
912
|
-
};
|
|
913
|
-
|
|
914
|
-
log?.info?.(
|
|
915
|
-
`[${CHANNEL_ID}] sending text reply to TuiTui -
|
|
916
|
-
target_users: [${outboundMsg.tousers.join(',')}],
|
|
917
|
-
target_groups: [${outboundMsg.togroups.join(',')}]
|
|
918
|
-
chatType=${chatType},
|
|
919
|
-
tousers=${JSON.stringify(outboundMsg.tousers)},
|
|
920
|
-
togroups=${JSON.stringify(outboundMsg.togroups)},
|
|
921
|
-
at=${JSON.stringify((outboundMsg as any).at ?? [])},
|
|
922
|
-
userAccount=${String(userAccount)},
|
|
923
|
-
groupId=${String(groupId)}`,
|
|
924
|
-
);
|
|
925
|
-
|
|
926
|
-
await postTuituiMsg(account, outboundMsg, 'tuitui.deliver.text', log);
|
|
927
|
-
} catch (e) {
|
|
928
|
-
log?.error?.(`[${CHANNEL_ID}] Error sending reply to TuiTui: ${e}`);
|
|
929
|
-
}
|
|
660
|
+
const at = chatTypeIsGroup ? [msgUname] : [];
|
|
661
|
+
await sendMediaMsg(account, chatTarget, chatTypeIsGroup, mediaUrl, 'tuitui.deliver.media', at);
|
|
930
662
|
}
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// Handle text messages
|
|
666
|
+
const replyText = payload?.text || payload?.body;
|
|
667
|
+
if (replyText) {
|
|
668
|
+
// at 使用 userAccount(群聊 @ 用户)
|
|
669
|
+
const atList = chatTypeIsGroup && userAccount ? [userAccount] : [];
|
|
670
|
+
// 群消息回复时不再使用 reference_msgid 避免并发时引用错乱,依靠 @ 来提醒用户
|
|
671
|
+
await sendTextMsg(account, chatTarget, chatTypeIsGroup, replyText, 'tuitui.deliver.text', atList);
|
|
931
672
|
}
|
|
932
673
|
},
|
|
933
674
|
onReplyStart: () => {
|
|
934
|
-
log?.info?.(`[${CHANNEL_ID}] Agent reply started for ${userAccount ??
|
|
675
|
+
log?.info?.(`[${CHANNEL_ID}] Agent reply started for ${userAccount ?? msgUid}`);
|
|
935
676
|
},
|
|
936
677
|
},
|
|
937
678
|
});
|
package/src/types.ts
CHANGED
|
@@ -64,7 +64,7 @@ export interface TuiTuiOutboundLinkMessage {
|
|
|
64
64
|
export interface TuiTuiOutboundImageMessage {
|
|
65
65
|
tousers?: string[];
|
|
66
66
|
togroups?: string[];
|
|
67
|
-
at?: string;
|
|
67
|
+
at?: string[];
|
|
68
68
|
msgtype: 'image';
|
|
69
69
|
image: { media_id: string };
|
|
70
70
|
}
|
|
@@ -72,37 +72,31 @@ export interface TuiTuiOutboundImageMessage {
|
|
|
72
72
|
export interface TuiTuiOutboundAttachmentMessage {
|
|
73
73
|
tousers?: string[];
|
|
74
74
|
togroups?: string[];
|
|
75
|
-
at?: string;
|
|
75
|
+
at?: string[];
|
|
76
76
|
msgtype: 'attachment';
|
|
77
77
|
attachment: { media_id: string };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
export interface TuiTuiOutboundPageMessagePage {
|
|
81
|
+
title: string;
|
|
82
|
+
summary?: string;
|
|
83
|
+
content: string;
|
|
84
|
+
image?: string;
|
|
85
|
+
format?: 'html';
|
|
86
|
+
privilege?: 'specific' | 'scope' | 'corp' | 'any';
|
|
87
|
+
delims_left?: string;
|
|
88
|
+
delims_right?: string;
|
|
89
|
+
kv?: Record<string, string>;
|
|
90
|
+
default_value?: string;
|
|
91
|
+
debug?: boolean;
|
|
92
|
+
}
|
|
80
93
|
export interface TuiTuiOutboundPageMessage {
|
|
81
94
|
tousers: string[];
|
|
82
95
|
togroups: string[];
|
|
83
96
|
msgtype: 'page';
|
|
84
|
-
page:
|
|
85
|
-
title: string;
|
|
86
|
-
summary?: string;
|
|
87
|
-
content: string;
|
|
88
|
-
image?: string;
|
|
89
|
-
format?: 'html';
|
|
90
|
-
privilege?: 'specific' | 'scope' | 'corp' | 'any';
|
|
91
|
-
delims_left?: string;
|
|
92
|
-
delims_right?: string;
|
|
93
|
-
kv?: Record<string, string>;
|
|
94
|
-
default_value?: string;
|
|
95
|
-
debug?: boolean;
|
|
96
|
-
};
|
|
97
|
+
page: TuiTuiOutboundPageMessagePage;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
export type TuiTuiOutboundMessage =
|
|
100
|
-
| TuiTuiOutboundTextMessage
|
|
101
|
-
| TuiTuiOutboundLinkMessage
|
|
102
|
-
| TuiTuiOutboundImageMessage
|
|
103
|
-
| TuiTuiOutboundAttachmentMessage
|
|
104
|
-
| TuiTuiOutboundPageMessage;
|
|
105
|
-
|
|
106
100
|
export interface TuiTuiMediaUploadResponse {
|
|
107
101
|
errcode: number;
|
|
108
102
|
errmsg: string;
|
package/src/utils.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
3
|
-
import type { IncomingMessage } from 'node:http';
|
|
4
2
|
import { basename } from 'node:path';
|
|
5
3
|
import { fetchWithSsrFGuard } from 'openclaw/plugin-sdk';
|
|
6
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
TuiTuiMessageData,
|
|
6
|
+
TuiTuiMediaUploadResponse,
|
|
7
|
+
TuiTuiSingleEmojiReactionTarget,
|
|
8
|
+
TuiTuiGroupEmojiReactionTarget,
|
|
9
|
+
TuiTuiOutboundTextMessage,
|
|
10
|
+
TuiTuiOutboundPageMessage,
|
|
11
|
+
TuiTuiOutboundPageMessagePage,
|
|
12
|
+
TuiTuiOutboundImageMessage,
|
|
13
|
+
TuiTuiOutboundAttachmentMessage
|
|
14
|
+
} from './types';
|
|
7
15
|
|
|
8
16
|
/* 一些常量配置 */
|
|
9
17
|
export const CHANNEL_ID = 'tuitui';
|
|
@@ -41,11 +49,11 @@ function _fetch(opts: any): Promise<any> {
|
|
|
41
49
|
return fetchWithSsrFGuard({
|
|
42
50
|
//url: fileSrc,
|
|
43
51
|
policy: TUITUI_SSRF_POLICY,
|
|
44
|
-
//
|
|
52
|
+
//auditCtx: "tuitui.media.download",
|
|
45
53
|
...opts,
|
|
46
54
|
})
|
|
47
55
|
}
|
|
48
|
-
function _fetchJson(url: string, json: any,
|
|
56
|
+
function _fetchJson(url: string, json: any, auditCtx: string): Promise<any> {
|
|
49
57
|
return _fetch({
|
|
50
58
|
url,
|
|
51
59
|
init: {
|
|
@@ -53,42 +61,31 @@ function _fetchJson(url: string, json: any, auditContext: string): Promise<any>
|
|
|
53
61
|
headers: { 'Content-Type': 'application/json' },
|
|
54
62
|
body: JSON.stringify(json),
|
|
55
63
|
},
|
|
56
|
-
|
|
64
|
+
auditCtx,
|
|
57
65
|
});
|
|
58
66
|
}
|
|
59
|
-
export async function postTuituiMsg(account: any, json: any,
|
|
67
|
+
export async function postTuituiMsg(account: any, json: any, auditCtx: string): Promise<any> {
|
|
60
68
|
const { appId: appid, appSecret: secret } = account;
|
|
61
69
|
const { response, release } = await _fetchJson(
|
|
62
70
|
addParams2Url('https://im.live.360.cn:8282/robot/message/custom/send', { appid, secret }),
|
|
63
71
|
json,
|
|
64
|
-
|
|
72
|
+
auditCtx,
|
|
65
73
|
);
|
|
66
74
|
try {
|
|
67
75
|
const bodyText = await response.text();
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
parsed = bodyText ? JSON.parse(bodyText) : null;
|
|
71
|
-
} catch(err) {
|
|
72
|
-
parsed = null;
|
|
73
|
-
}
|
|
76
|
+
const parsed = JSON.parse(bodyText);
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
`[${CHANNEL_ID}] ${auditContext} response status=${response.status} ok=${response.ok} body=${bodyText || '<empty>'}`,
|
|
77
|
-
);
|
|
78
|
+
console.debug(`[${CHANNEL_ID}] ${auditCtx} postTuituiMsg response status=${response.status} ok=${response.ok} body=${bodyText || '<empty>'}`);
|
|
78
79
|
|
|
79
80
|
if (!response.ok) {
|
|
80
|
-
throw new Error(
|
|
81
|
-
`[${CHANNEL_ID}] ${auditContext} Failed (response.ok unexpected): ${response.status} ${response.statusText}; body=${bodyText || '<empty>'}`,
|
|
82
|
-
);
|
|
81
|
+
throw new Error(`postTuituiMsg Failed (response.ok unexpected): ${response.status} ${response.statusText}; body=${bodyText || '<empty>'}`);
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
if (parsed
|
|
86
|
-
throw new Error(
|
|
87
|
-
`[${CHANNEL_ID}] ${auditContext} Failed (errcode unexpected): errcode=${parsed.errcode} errmsg=${parsed.errmsg ?? 'Unknown error'}`,
|
|
88
|
-
);
|
|
84
|
+
if (Number(parsed?.errcode) !== 0) {
|
|
85
|
+
throw new Error(`postTuituiMsg Failed (errcode unexpected): errcode=${parsed.errcode} errmsg=${parsed.errmsg ?? 'Unknown error'}`);
|
|
89
86
|
}
|
|
90
87
|
} catch(err) {
|
|
91
|
-
console.error(`[${CHANNEL_ID}] postTuituiMsg error:`, err, `\njson: ${JSON.stringify(json)}`);
|
|
88
|
+
console.error(`[${CHANNEL_ID}] ${auditCtx} postTuituiMsg error:`, err, `\njson: ${JSON.stringify(json)}`);
|
|
92
89
|
} finally {
|
|
93
90
|
await release();
|
|
94
91
|
}
|
|
@@ -148,7 +145,7 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
|
|
|
148
145
|
}
|
|
149
146
|
// HTTP/HTTPS URL
|
|
150
147
|
else if (/^https?\:/.test(fileSrc)) {
|
|
151
|
-
const { response, release } = await _fetch({ url: fileSrc,
|
|
148
|
+
const { response, release } = await _fetch({ url: fileSrc, auditCtx: 'tuitui.media.download' });
|
|
152
149
|
try {
|
|
153
150
|
if (!response.ok) {
|
|
154
151
|
throw new Error(`[${CHANNEL_ID}] Failed to download media from ${fileSrc}: ${response.status}`);
|
|
@@ -201,7 +198,7 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
|
|
|
201
198
|
const { response, release } = await _fetch({
|
|
202
199
|
url: addParams2Url('https://im.live.360.cn:8282/robot/media/upload', { appid, secret, type }),
|
|
203
200
|
init: { method: "POST", body },
|
|
204
|
-
|
|
201
|
+
auditCtx: "tuitui.media.upload",
|
|
205
202
|
});
|
|
206
203
|
try {
|
|
207
204
|
if (!response.ok) {
|
|
@@ -228,10 +225,8 @@ export async function uploadFileToTuiTui(fileSrc: string, account: any, type: 'i
|
|
|
228
225
|
export function buildMessageBody(data: TuiTuiMessageData): string {
|
|
229
226
|
const parts: string[] = [];
|
|
230
227
|
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
const pushImgs = () => {
|
|
234
|
-
const imgs = data.images;
|
|
228
|
+
const pushTxt = () => parts.push(data.text || '');
|
|
229
|
+
const pushImgs = (imgs: string[] | undefined, parts: string[]) => {
|
|
235
230
|
if (!imgs) return;
|
|
236
231
|
const imgLen = imgs?.length || 0;
|
|
237
232
|
if (imgLen === 0) return;
|
|
@@ -249,10 +244,10 @@ export function buildMessageBody(data: TuiTuiMessageData): string {
|
|
|
249
244
|
break;
|
|
250
245
|
case 'mixed':
|
|
251
246
|
pushTxt();
|
|
252
|
-
pushImgs();
|
|
247
|
+
pushImgs(data.images, parts);
|
|
253
248
|
break;
|
|
254
249
|
case 'image':
|
|
255
|
-
pushImgs();
|
|
250
|
+
pushImgs(data.images, parts);
|
|
256
251
|
break;
|
|
257
252
|
case 'voice':
|
|
258
253
|
if (data.voice) parts.push(`[语音] ${data.voice}`);
|
|
@@ -265,14 +260,16 @@ export function buildMessageBody(data: TuiTuiMessageData): string {
|
|
|
265
260
|
// Handle reference/reply
|
|
266
261
|
const { ref } = data;
|
|
267
262
|
if (ref && ref.is_me) {
|
|
268
|
-
const { msg_type
|
|
263
|
+
const { msg_type } = ref;
|
|
269
264
|
let refContent = `[${msg_type}]`;
|
|
270
265
|
switch (msg_type) {
|
|
271
266
|
case 'text':
|
|
272
|
-
refContent = text || '';
|
|
267
|
+
refContent = ref.text || '';
|
|
273
268
|
break;
|
|
274
269
|
case 'image':
|
|
275
|
-
|
|
270
|
+
const refParts: string[] = [];
|
|
271
|
+
pushImgs(ref.images, refParts);
|
|
272
|
+
refContent = refParts.join("\n") || '[图片]';
|
|
276
273
|
break;
|
|
277
274
|
}
|
|
278
275
|
parts.unshift(`[引用机器人消息]\n> ${refContent}`);
|
|
@@ -287,11 +284,11 @@ export async function tuituiEmojiReaction(
|
|
|
287
284
|
targetIsGroup: boolean,
|
|
288
285
|
msgid: string,
|
|
289
286
|
emoji: string
|
|
290
|
-
): Promise<
|
|
287
|
+
): Promise<void> {
|
|
291
288
|
const payload = {
|
|
292
289
|
msgtype: 'emoji_reaction',
|
|
293
|
-
tousers:[] as TuiTuiSingleEmojiReactionTarget[],
|
|
294
|
-
togroups:[] as TuiTuiGroupEmojiReactionTarget[],
|
|
290
|
+
tousers: [] as TuiTuiSingleEmojiReactionTarget[],
|
|
291
|
+
togroups: [] as TuiTuiGroupEmojiReactionTarget[],
|
|
295
292
|
emoji_reaction: { emoji, cancel: false},
|
|
296
293
|
};
|
|
297
294
|
if(targetIsGroup) {
|
|
@@ -312,6 +309,84 @@ export async function tuituiEmojiReaction(
|
|
|
312
309
|
} finally {
|
|
313
310
|
await release();
|
|
314
311
|
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function getTargets(chatId: string, isGroup: boolean): { tousers: string[]; togroups: string[] } {
|
|
315
|
+
const tousers: string[] = [];
|
|
316
|
+
const togroups: string[] = [];
|
|
317
|
+
(isGroup ? togroups : tousers).push(chatId);
|
|
318
|
+
return { tousers, togroups };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function sendTextMsg(
|
|
322
|
+
account: any,
|
|
323
|
+
target: string | undefined,
|
|
324
|
+
isGroup: boolean,
|
|
325
|
+
content: string,
|
|
326
|
+
auditCtx: string = 'tuitui.send.text',
|
|
327
|
+
atList?: string[],
|
|
328
|
+
): Promise<void> {
|
|
329
|
+
if (!target) return console.error(`[${CHANNEL_ID}] sendTextMsg Error ${auditCtx}: Missing "target"`);
|
|
330
|
+
// Format message according to TuiTui's required structure
|
|
331
|
+
const msg: TuiTuiOutboundTextMessage = {
|
|
332
|
+
msgtype: 'text',
|
|
333
|
+
...getTargets(target, isGroup),
|
|
334
|
+
at: atList || [],
|
|
335
|
+
text: { content },
|
|
336
|
+
};
|
|
337
|
+
console.log(`[${CHANNEL_ID}] sendTextMsg ${auditCtx} - `, msg);
|
|
338
|
+
await postTuituiMsg(account, msg, auditCtx);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function sendPageMsg(
|
|
342
|
+
account: any,
|
|
343
|
+
target: string | undefined,
|
|
344
|
+
isGroup: boolean,
|
|
345
|
+
page: TuiTuiOutboundPageMessagePage,
|
|
346
|
+
auditCtx: string = 'tuitui.send.page',
|
|
347
|
+
tousers?: string[],
|
|
348
|
+
togroups?: string[],
|
|
349
|
+
): Promise<void> {
|
|
350
|
+
if (!target) return console.error(`[${CHANNEL_ID}] sendPageMsg Error ${auditCtx}: Missing "target"`);
|
|
351
|
+
const targets = getTargets(target, isGroup);
|
|
352
|
+
tousers = tousers || targets.tousers;
|
|
353
|
+
togroups = togroups || targets.togroups;
|
|
354
|
+
const msg: TuiTuiOutboundPageMessage = {
|
|
355
|
+
msgtype: 'page',
|
|
356
|
+
tousers,
|
|
357
|
+
togroups,
|
|
358
|
+
page: { ...(page || {})}
|
|
359
|
+
};
|
|
360
|
+
console.log(`[${CHANNEL_ID}] sendPageMsg ${auditCtx} - `, msg);
|
|
361
|
+
await postTuituiMsg(account, msg, auditCtx);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function sendMediaMsg(
|
|
365
|
+
account: any,
|
|
366
|
+
target: string | undefined,
|
|
367
|
+
isGroup: boolean,
|
|
368
|
+
mediaUrl: string,
|
|
369
|
+
auditCtx: string = 'tuitui.send.media',
|
|
370
|
+
at?: string[],
|
|
371
|
+
): Promise<void> {
|
|
372
|
+
if (!target) return console.error(`[${CHANNEL_ID}] sendMediaMsg Error ${auditCtx}: Missing "target"`);
|
|
373
|
+
// Check if mediaUrl looks like an image
|
|
374
|
+
const isImage = /^data:image\//i.test(mediaUrl) || /\.(jpg|jpeg|png|gif)(?:$|[?#])/i.test(mediaUrl);
|
|
375
|
+
const mediaType = isImage ? 'image' : 'file';
|
|
376
|
+
|
|
377
|
+
const media_id = await uploadFileToTuiTui(mediaUrl, account, mediaType);
|
|
378
|
+
if (!media_id) {
|
|
379
|
+
console.error(`[${CHANNEL_ID}] uploadFileToTuiTui failed ${auditCtx}, {mediaUrl: ${mediaUrl}, mediaType: ${mediaType}}`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const targets = getTargets(target, isGroup);
|
|
384
|
+
const msg: TuiTuiOutboundImageMessage | TuiTuiOutboundAttachmentMessage =
|
|
385
|
+
isImage
|
|
386
|
+
? { ...targets, msgtype: 'image', image: { media_id } }
|
|
387
|
+
: { ...targets, msgtype: 'attachment', attachment: { media_id } };
|
|
388
|
+
if (at && at.length > 0) msg.at = at;
|
|
389
|
+
console.log(`[${CHANNEL_ID}] sendMediaMsg ${auditCtx} - `, msg);
|
|
315
390
|
|
|
316
|
-
|
|
391
|
+
await postTuituiMsg(account, msg, auditCtx);
|
|
317
392
|
}
|