@zhin.js/adapter-qq 1.0.84 → 1.1.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/CHANGELOG.md +844 -17
- package/README.md +136 -233
- package/adapters/qq.js +50 -0
- package/adapters/qq.ts +71 -0
- package/agent/PERMITS.md +25 -0
- package/{skills/qq/SKILL.md → agent/skills/qq.md} +1 -1
- package/agent/tools/add_role.ts +21 -0
- package/agent/tools/channel_info.ts +17 -0
- package/agent/tools/create_role.ts +20 -0
- package/agent/tools/list_channels.ts +18 -0
- package/agent/tools/list_guilds.ts +14 -0
- package/agent/tools/list_roles.ts +18 -0
- package/agent/tools/member_detail.ts +18 -0
- package/agent/tools/remove_role.ts +21 -0
- package/commands/endpoint/add/[[id]].js +3 -0
- package/commands/endpoint/add/[[id]].ts +3 -0
- package/commands/endpoint/cancel.js +12 -0
- package/commands/endpoint/cancel.ts +15 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +12 -0
- package/lib/client.js +2 -0
- package/lib/endpoint.d.ts +56 -0
- package/lib/endpoint.js +456 -0
- package/lib/index.d.ts +8 -18
- package/lib/index.js +8 -338
- package/lib/outbound.d.ts +17 -0
- package/lib/outbound.js +234 -0
- package/lib/platform-permit.d.ts +15 -0
- package/lib/platform-permit.js +41 -0
- package/lib/protocol.d.ts +111 -0
- package/lib/protocol.js +166 -0
- package/lib/qq-bind-api.d.ts +36 -0
- package/lib/qq-bind-api.js +89 -0
- package/lib/qq-bind-flow.d.ts +19 -0
- package/lib/qq-bind-flow.js +85 -0
- package/lib/qq-bind-persist.d.ts +12 -0
- package/lib/qq-bind-persist.js +22 -0
- package/lib/qq-bot-kind-prompt.d.ts +9 -0
- package/lib/qq-bot-kind-prompt.js +37 -0
- package/lib/qq-endpoint-commands.d.ts +45 -0
- package/lib/qq-endpoint-commands.js +180 -0
- package/lib/qq-endpoint-config.d.ts +17 -0
- package/lib/qq-endpoint-config.js +22 -0
- package/lib/qq-intents.d.ts +27 -0
- package/lib/qq-intents.js +51 -0
- package/lib/qq-runtime-state.d.ts +30 -0
- package/lib/qq-runtime-state.js +14 -0
- package/lib/side-event-dispatch.d.ts +10 -0
- package/lib/side-event-dispatch.js +114 -0
- package/lib/webhook.d.ts +18 -0
- package/lib/webhook.js +103 -0
- package/lib/ws.d.ts +55 -0
- package/lib/ws.js +234 -0
- package/middlewares/bot-kind-reply.js +46 -0
- package/middlewares/bot-kind-reply.ts +60 -0
- package/package.json +69 -28
- package/plugin.js +18 -0
- package/schema.json +142 -0
- package/src/client.ts +16 -0
- package/src/endpoint.ts +534 -0
- package/src/index.ts +66 -326
- package/src/outbound.ts +255 -0
- package/src/platform-permit.ts +56 -0
- package/src/protocol.ts +273 -0
- package/src/qq-bind-api.ts +145 -0
- package/src/qq-bind-flow.ts +127 -0
- package/src/qq-bind-persist.ts +44 -0
- package/src/qq-bot-kind-prompt.ts +47 -0
- package/src/qq-endpoint-commands.ts +251 -0
- package/src/qq-endpoint-config.ts +44 -0
- package/src/qq-intents.ts +77 -0
- package/src/qq-runtime-state.ts +51 -0
- package/src/side-event-dispatch.ts +140 -0
- package/src/webhook.ts +132 -0
- package/src/ws.ts +331 -0
- package/client/Dashboard.tsx +0 -230
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/client/utils/api.ts +0 -17
- package/dist/index.js +0 -30
- package/lib/adapter.d.ts +0 -17
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -49
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -102
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -337
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -16
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -58
- package/src/bot.ts +0 -347
- package/src/types.ts +0 -24
package/src/index.ts
CHANGED
|
@@ -1,326 +1,66 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return { guilds, count: guilds.length };
|
|
68
|
-
},
|
|
69
|
-
}, plugin.name));
|
|
70
|
-
|
|
71
|
-
// 获取子频道列表
|
|
72
|
-
disposers.push(toolService.addTool({
|
|
73
|
-
name: 'qq_list_channels',
|
|
74
|
-
description: '获取 QQ 频道下的子频道列表',
|
|
75
|
-
parameters: {
|
|
76
|
-
type: 'object',
|
|
77
|
-
properties: {
|
|
78
|
-
bot: { type: 'string', description: 'Bot 名称' },
|
|
79
|
-
guild_id: { type: 'string', description: '频道 ID' },
|
|
80
|
-
},
|
|
81
|
-
required: ['bot', 'guild_id'],
|
|
82
|
-
},
|
|
83
|
-
platforms: ['qq'],
|
|
84
|
-
tags: ['qq'],
|
|
85
|
-
execute: async (args: Record<string, any>) => {
|
|
86
|
-
const bot = qq.bots.get(args.bot);
|
|
87
|
-
if (!bot) throw new Error(`Bot ${args.bot} 不存在`);
|
|
88
|
-
const channels = await bot.getChannels(args.guild_id);
|
|
89
|
-
return { channels, count: channels.length };
|
|
90
|
-
},
|
|
91
|
-
}, plugin.name));
|
|
92
|
-
|
|
93
|
-
// 获取角色列表
|
|
94
|
-
disposers.push(toolService.addTool({
|
|
95
|
-
name: 'qq_list_roles',
|
|
96
|
-
description: '获取 QQ 频道角色列表',
|
|
97
|
-
parameters: {
|
|
98
|
-
type: 'object',
|
|
99
|
-
properties: {
|
|
100
|
-
bot: { type: 'string', description: 'Bot 名称' },
|
|
101
|
-
guild_id: { type: 'string', description: '频道 ID' },
|
|
102
|
-
},
|
|
103
|
-
required: ['bot', 'guild_id'],
|
|
104
|
-
},
|
|
105
|
-
platforms: ['qq'],
|
|
106
|
-
tags: ['qq'],
|
|
107
|
-
execute: async (args: Record<string, any>) => {
|
|
108
|
-
const bot = qq.bots.get(args.bot);
|
|
109
|
-
if (!bot) throw new Error(`Bot ${args.bot} 不存在`);
|
|
110
|
-
const roles = await bot.getGuildRoles(args.guild_id);
|
|
111
|
-
return { roles, count: roles.length };
|
|
112
|
-
},
|
|
113
|
-
}, plugin.name));
|
|
114
|
-
|
|
115
|
-
// 创建角色
|
|
116
|
-
disposers.push(toolService.addTool({
|
|
117
|
-
name: 'qq_create_role',
|
|
118
|
-
description: '创建 QQ 频道角色',
|
|
119
|
-
parameters: {
|
|
120
|
-
type: 'object',
|
|
121
|
-
properties: {
|
|
122
|
-
bot: { type: 'string', description: 'Bot 名称' },
|
|
123
|
-
guild_id: { type: 'string', description: '频道 ID' },
|
|
124
|
-
name: { type: 'string', description: '角色名称' },
|
|
125
|
-
color: { type: 'number', description: '颜色(RGB 十进制数值)' },
|
|
126
|
-
},
|
|
127
|
-
required: ['bot', 'guild_id', 'name'],
|
|
128
|
-
},
|
|
129
|
-
platforms: ['qq'],
|
|
130
|
-
tags: ['qq'],
|
|
131
|
-
execute: async (args: Record<string, any>) => {
|
|
132
|
-
const bot = qq.bots.get(args.bot);
|
|
133
|
-
if (!bot) throw new Error(`Bot ${args.bot} 不存在`);
|
|
134
|
-
const role = await bot.createGuildRole(args.guild_id, args.name, args.color);
|
|
135
|
-
return { success: !!role, role, message: role ? `角色 "${args.name}" 创建成功` : '创建失败' };
|
|
136
|
-
},
|
|
137
|
-
}, plugin.name));
|
|
138
|
-
|
|
139
|
-
// 添加角色
|
|
140
|
-
disposers.push(toolService.addTool({
|
|
141
|
-
name: 'qq_add_role',
|
|
142
|
-
description: '给成员添加 QQ 频道角色',
|
|
143
|
-
parameters: {
|
|
144
|
-
type: 'object',
|
|
145
|
-
properties: {
|
|
146
|
-
bot: { type: 'string', description: 'Bot 名称' },
|
|
147
|
-
guild_id: { type: 'string', description: '频道 ID' },
|
|
148
|
-
channel_id: { type: 'string', description: '子频道 ID' },
|
|
149
|
-
user_id: { type: 'string', description: '用户 ID' },
|
|
150
|
-
role_id: { type: 'string', description: '角色 ID' },
|
|
151
|
-
},
|
|
152
|
-
required: ['bot', 'guild_id', 'channel_id', 'user_id', 'role_id'],
|
|
153
|
-
},
|
|
154
|
-
platforms: ['qq'],
|
|
155
|
-
tags: ['qq'],
|
|
156
|
-
execute: async (args: Record<string, any>) => {
|
|
157
|
-
const bot = qq.bots.get(args.bot);
|
|
158
|
-
if (!bot) throw new Error(`Bot ${args.bot} 不存在`);
|
|
159
|
-
const success = await bot.addMemberRole(args.guild_id, args.channel_id, args.user_id, args.role_id);
|
|
160
|
-
return { success, message: success ? '已给成员添加角色' : '操作失败' };
|
|
161
|
-
},
|
|
162
|
-
}, plugin.name));
|
|
163
|
-
|
|
164
|
-
// 移除角色
|
|
165
|
-
disposers.push(toolService.addTool({
|
|
166
|
-
name: 'qq_remove_role',
|
|
167
|
-
description: '移除成员的 QQ 频道角色',
|
|
168
|
-
parameters: {
|
|
169
|
-
type: 'object',
|
|
170
|
-
properties: {
|
|
171
|
-
bot: { type: 'string', description: 'Bot 名称' },
|
|
172
|
-
guild_id: { type: 'string', description: '频道 ID' },
|
|
173
|
-
channel_id: { type: 'string', description: '子频道 ID' },
|
|
174
|
-
user_id: { type: 'string', description: '用户 ID' },
|
|
175
|
-
role_id: { type: 'string', description: '角色 ID' },
|
|
176
|
-
},
|
|
177
|
-
required: ['bot', 'guild_id', 'channel_id', 'user_id', 'role_id'],
|
|
178
|
-
},
|
|
179
|
-
platforms: ['qq'],
|
|
180
|
-
tags: ['qq'],
|
|
181
|
-
execute: async (args: Record<string, any>) => {
|
|
182
|
-
const bot = qq.bots.get(args.bot);
|
|
183
|
-
if (!bot) throw new Error(`Bot ${args.bot} 不存在`);
|
|
184
|
-
const success = await bot.removeMemberRole(args.guild_id, args.channel_id, args.user_id, args.role_id);
|
|
185
|
-
return { success, message: success ? '已移除成员的角色' : '操作失败' };
|
|
186
|
-
},
|
|
187
|
-
}, plugin.name));
|
|
188
|
-
|
|
189
|
-
// 子频道详情
|
|
190
|
-
disposers.push(toolService.addTool({
|
|
191
|
-
name: 'qq_channel_info',
|
|
192
|
-
description: '获取 QQ 频道中指定子频道的详细信息',
|
|
193
|
-
parameters: {
|
|
194
|
-
type: 'object',
|
|
195
|
-
properties: {
|
|
196
|
-
bot: { type: 'string', description: 'Bot 名称' },
|
|
197
|
-
channel_id: { type: 'string', description: '子频道 ID' },
|
|
198
|
-
},
|
|
199
|
-
required: ['bot', 'channel_id'],
|
|
200
|
-
},
|
|
201
|
-
platforms: ['qq'],
|
|
202
|
-
tags: ['qq'],
|
|
203
|
-
execute: async (args: Record<string, any>) => {
|
|
204
|
-
const bot = qq.bots.get(args.bot);
|
|
205
|
-
if (!bot) throw new Error(`Bot ${args.bot} 不存在`);
|
|
206
|
-
const info = await bot.getChannelInfo(args.channel_id);
|
|
207
|
-
return info;
|
|
208
|
-
},
|
|
209
|
-
}, plugin.name));
|
|
210
|
-
|
|
211
|
-
// 单成员详情
|
|
212
|
-
disposers.push(toolService.addTool({
|
|
213
|
-
name: 'qq_member_detail',
|
|
214
|
-
description: '获取 QQ 频道中指定成员的详细信息',
|
|
215
|
-
parameters: {
|
|
216
|
-
type: 'object',
|
|
217
|
-
properties: {
|
|
218
|
-
bot: { type: 'string', description: 'Bot 名称' },
|
|
219
|
-
guild_id: { type: 'string', description: '频道 ID' },
|
|
220
|
-
user_id: { type: 'string', description: '用户 ID' },
|
|
221
|
-
},
|
|
222
|
-
required: ['bot', 'guild_id', 'user_id'],
|
|
223
|
-
},
|
|
224
|
-
platforms: ['qq'],
|
|
225
|
-
tags: ['qq'],
|
|
226
|
-
execute: async (args: Record<string, any>) => {
|
|
227
|
-
const bot = qq.bots.get(args.bot);
|
|
228
|
-
if (!bot) throw new Error(`Bot ${args.bot} 不存在`);
|
|
229
|
-
const member = await bot.getGuildMember(args.guild_id, args.user_id);
|
|
230
|
-
return member;
|
|
231
|
-
},
|
|
232
|
-
}, plugin.name));
|
|
233
|
-
|
|
234
|
-
return () => disposers.forEach(d => d());
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
// ── Web 控制台 ─────────────────────────────────────────────────────────
|
|
238
|
-
useContext("web", (pageManager) => {
|
|
239
|
-
pageManager.addEntry({
|
|
240
|
-
id: "qq",
|
|
241
|
-
development: path.resolve(import.meta.dirname, "../client/index.tsx"),
|
|
242
|
-
production: path.resolve(import.meta.dirname, "../dist/index.js"),
|
|
243
|
-
meta: { name: "QQ" },
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
useContext("router", "qq", (router: any, qq: QQAdapter) => {
|
|
248
|
-
router.get("/api/qq/bots", async (ctx: any) => {
|
|
249
|
-
try {
|
|
250
|
-
const bots = Array.from(qq.bots.values());
|
|
251
|
-
const result = await Promise.all(bots.map(async (bot) => {
|
|
252
|
-
try {
|
|
253
|
-
let guildCount = 0;
|
|
254
|
-
try { const guilds = await bot.getGuilds(); guildCount = guilds?.length || 0; } catch {}
|
|
255
|
-
return {
|
|
256
|
-
name: bot.$config.name,
|
|
257
|
-
connected: bot.$connected || false,
|
|
258
|
-
guildCount,
|
|
259
|
-
status: bot.$connected ? "online" : "offline",
|
|
260
|
-
};
|
|
261
|
-
} catch {
|
|
262
|
-
return { name: bot.$config.name, connected: false, guildCount: 0, status: "error" };
|
|
263
|
-
}
|
|
264
|
-
}));
|
|
265
|
-
ctx.body = { success: true, data: result };
|
|
266
|
-
} catch {
|
|
267
|
-
ctx.status = 500;
|
|
268
|
-
ctx.body = { success: false, error: "获取机器人数据失败" };
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
// Bot 连接/断开
|
|
273
|
-
router.post("/api/qq/bots/:name/connect", async (ctx: any) => {
|
|
274
|
-
try {
|
|
275
|
-
const bot = qq.bots.get(ctx.params.name);
|
|
276
|
-
if (!bot) { ctx.status = 404; ctx.body = { success: false, error: "Bot 不存在" }; return; }
|
|
277
|
-
if (bot.$connected) { ctx.body = { success: true, message: "已经在线" }; return; }
|
|
278
|
-
await bot.$connect();
|
|
279
|
-
ctx.body = { success: true, message: "连接成功" };
|
|
280
|
-
} catch (e: any) {
|
|
281
|
-
ctx.status = 500;
|
|
282
|
-
ctx.body = { success: false, error: e?.message || "连接失败" };
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
router.post("/api/qq/bots/:name/disconnect", async (ctx: any) => {
|
|
287
|
-
try {
|
|
288
|
-
const bot = qq.bots.get(ctx.params.name);
|
|
289
|
-
if (!bot) { ctx.status = 404; ctx.body = { success: false, error: "Bot 不存在" }; return; }
|
|
290
|
-
if (!bot.$connected) { ctx.body = { success: true, message: "已经离线" }; return; }
|
|
291
|
-
await bot.$disconnect();
|
|
292
|
-
ctx.body = { success: true, message: "已断开" };
|
|
293
|
-
} catch (e: any) {
|
|
294
|
-
ctx.status = 500;
|
|
295
|
-
ctx.body = { success: false, error: e?.message || "断开失败" };
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
// 频道列表
|
|
300
|
-
router.get("/api/qq/bots/:name/guilds", async (ctx: any) => {
|
|
301
|
-
try {
|
|
302
|
-
const bot = qq.bots.get(ctx.params.name);
|
|
303
|
-
if (!bot) { ctx.status = 404; ctx.body = { success: false, error: "Bot 不存在" }; return; }
|
|
304
|
-
if (!bot.$connected) { ctx.status = 400; ctx.body = { success: false, error: "Bot 未连接" }; return; }
|
|
305
|
-
const guilds = await bot.getGuilds();
|
|
306
|
-
ctx.body = { success: true, data: guilds || [] };
|
|
307
|
-
} catch (e: any) {
|
|
308
|
-
ctx.status = 500;
|
|
309
|
-
ctx.body = { success: false, error: e?.message || "获取频道失败" };
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
// 子频道列表
|
|
314
|
-
router.get("/api/qq/bots/:name/guilds/:guildId/channels", async (ctx: any) => {
|
|
315
|
-
try {
|
|
316
|
-
const bot = qq.bots.get(ctx.params.name);
|
|
317
|
-
if (!bot) { ctx.status = 404; ctx.body = { success: false, error: "Bot 不存在" }; return; }
|
|
318
|
-
if (!bot.$connected) { ctx.status = 400; ctx.body = { success: false, error: "Bot 未连接" }; return; }
|
|
319
|
-
const channels = await bot.getChannels(ctx.params.guildId);
|
|
320
|
-
ctx.body = { success: true, data: channels || [] };
|
|
321
|
-
} catch (e: any) {
|
|
322
|
-
ctx.status = 500;
|
|
323
|
-
ctx.body = { success: false, error: e?.message || "获取子频道失败" };
|
|
324
|
-
}
|
|
325
|
-
});
|
|
326
|
-
});
|
|
1
|
+
export {
|
|
2
|
+
formatInboundContent,
|
|
3
|
+
formatOutboundText,
|
|
4
|
+
resolveOutboundMessageId,
|
|
5
|
+
resolveQqConfig,
|
|
6
|
+
senderDisplayName,
|
|
7
|
+
type QqAdapterConfig,
|
|
8
|
+
type QqChannelKind,
|
|
9
|
+
type QqInboundMessage,
|
|
10
|
+
type QqWireSegment,
|
|
11
|
+
type ResolvedQqConfig,
|
|
12
|
+
type ResolvedQqHttpConfig,
|
|
13
|
+
type ResolvedQqWebsocketConfig,
|
|
14
|
+
} from './protocol.js';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
DEFAULT_QQ_BOT_KIND,
|
|
18
|
+
QQ_INTENTS_BY_KIND,
|
|
19
|
+
defaultQqEndpointIntentFields,
|
|
20
|
+
parseQqBotKind,
|
|
21
|
+
resolveQqIntents,
|
|
22
|
+
type QqBotKind,
|
|
23
|
+
type QqIntent,
|
|
24
|
+
} from './qq-intents.js';
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
formatOutbound,
|
|
28
|
+
resolveMediaFile,
|
|
29
|
+
type QqOutboundElem,
|
|
30
|
+
type QqOutboundMessage,
|
|
31
|
+
} from './outbound.js';
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
QqHttpEndpoint,
|
|
35
|
+
QqWebsocketEndpoint,
|
|
36
|
+
type CreateQqBot,
|
|
37
|
+
type CreateQqHttpBot,
|
|
38
|
+
type QqBotTransport,
|
|
39
|
+
type QqOfficialApi,
|
|
40
|
+
type QqEndpointOptions,
|
|
41
|
+
type QqHttpBotTransport,
|
|
42
|
+
type QqHttpEndpointOptions,
|
|
43
|
+
} from './endpoint.js';
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
registerQqWebhookRoutes,
|
|
47
|
+
handleQqWebhookRequest,
|
|
48
|
+
readRequestBodyText,
|
|
49
|
+
defaultCreateHttpBot,
|
|
50
|
+
type QqWebhookHandler,
|
|
51
|
+
} from './webhook.js';
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
bindQqBotInboundEvents,
|
|
55
|
+
defaultCreateBot,
|
|
56
|
+
normalizeQqMessage,
|
|
57
|
+
} from './ws.js';
|
|
58
|
+
|
|
59
|
+
export { qqClient, type QqClientEventMap } from './client.js';
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
checkQqPlatformPermit,
|
|
63
|
+
normalizeQqGuildSenderForPermit,
|
|
64
|
+
platformPermit,
|
|
65
|
+
qqGuildPermitResolver,
|
|
66
|
+
} from './platform-permit.js';
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ 官方出站富媒体归一化:把渲染后的 payload 转为 qq-official-bot 可识别的
|
|
3
|
+
* Sendable 结构(文本串 + 消息段)。移植自 legacy 版 outbound-media /
|
|
4
|
+
* outbound-markdown / outbound-keyboard。
|
|
5
|
+
*
|
|
6
|
+
* 移植面:
|
|
7
|
+
* - image:http(s) URL 直发(data.url);base64://、data:...;base64、本地路径
|
|
8
|
+
* 归一到 data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
|
|
9
|
+
* - markdown:data.content 原文,或 custom_template_id + params 模板。
|
|
10
|
+
* - keyboard:data.rows 按钮矩阵展开为 button 段(SDK processButtons 组装
|
|
11
|
+
* keyboard 载荷);按钮消息必须带 markdown,故同行文本合并为 markdown 段。
|
|
12
|
+
* data.id 模板键盘原样透传。
|
|
13
|
+
* 不可映射的段一律降级为文本并 warn(保持既有 fallback 行为)。
|
|
14
|
+
*/
|
|
15
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
16
|
+
import { isMediaRef } from '@zhin.js/core';
|
|
17
|
+
import type { QqWireSegment } from './protocol.js';
|
|
18
|
+
|
|
19
|
+
const logger = getLogger('qq');
|
|
20
|
+
|
|
21
|
+
/** qq-official-bot Sendable 的最小结构子集(字符串模板或消息段)。 */
|
|
22
|
+
export interface QqOutboundElem {
|
|
23
|
+
readonly type: string;
|
|
24
|
+
readonly data: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type QqOutboundMessage = string | QqOutboundElem | readonly (string | QqOutboundElem)[];
|
|
28
|
+
|
|
29
|
+
interface QqOutboundButtonSpec {
|
|
30
|
+
readonly id?: string;
|
|
31
|
+
readonly label?: string;
|
|
32
|
+
readonly payload?: string;
|
|
33
|
+
readonly disabled?: boolean;
|
|
34
|
+
readonly style?: 'primary' | 'danger' | 'secondary';
|
|
35
|
+
readonly mode?: string;
|
|
36
|
+
readonly command?: { readonly enter?: boolean; readonly reply?: boolean };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 解析 image/audio/video/file 段的投递源(canonical MediaRef 唯一来源):
|
|
41
|
+
* - kind=url → data.url 直发;
|
|
42
|
+
* - kind=base64 / path / file → data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
|
|
43
|
+
*/
|
|
44
|
+
export function resolveMediaFile(data: Record<string, unknown>): string | undefined {
|
|
45
|
+
const media = data.media;
|
|
46
|
+
if (!isMediaRef(media)) return undefined;
|
|
47
|
+
if (media.kind === 'url') return undefined;
|
|
48
|
+
if (media.kind === 'base64') {
|
|
49
|
+
return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
|
|
50
|
+
}
|
|
51
|
+
return media.value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeMediaSegment(seg: QqWireSegment): QqOutboundElem | null {
|
|
55
|
+
const media = (seg.data ?? {}).media;
|
|
56
|
+
if (!isMediaRef(media)) {
|
|
57
|
+
// 无 canonical 媒体引用:无法投递,降级留痕
|
|
58
|
+
logger.warn(formatCompact({
|
|
59
|
+
op: 'qq_outbound_media_dropped',
|
|
60
|
+
type: seg.type,
|
|
61
|
+
reason: 'missing_media_ref',
|
|
62
|
+
}));
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (media.kind === 'url') {
|
|
66
|
+
return { type: seg.type, data: { url: media.value } };
|
|
67
|
+
}
|
|
68
|
+
const file = resolveMediaFile(seg.data ?? {});
|
|
69
|
+
if (!file) {
|
|
70
|
+
logger.warn(formatCompact({
|
|
71
|
+
op: 'qq_outbound_media_dropped',
|
|
72
|
+
type: seg.type,
|
|
73
|
+
reason: 'missing_source',
|
|
74
|
+
}));
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return { type: seg.type, data: { file } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeMarkdownSegment(seg: QqWireSegment): QqOutboundElem | null {
|
|
81
|
+
const data = seg.data ?? {};
|
|
82
|
+
const templateId = typeof data.custom_template_id === 'string' && data.custom_template_id
|
|
83
|
+
? data.custom_template_id
|
|
84
|
+
: typeof data.template_id === 'string' && data.template_id
|
|
85
|
+
? data.template_id
|
|
86
|
+
: undefined;
|
|
87
|
+
if (templateId) {
|
|
88
|
+
return {
|
|
89
|
+
type: 'markdown',
|
|
90
|
+
data: {
|
|
91
|
+
custom_template_id: templateId,
|
|
92
|
+
params: Array.isArray(data.params) ? data.params : [],
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const content = String(data.content ?? data.text ?? '');
|
|
97
|
+
if (!content.trim()) {
|
|
98
|
+
logger.warn(formatCompact({ op: 'qq_outbound_markdown_dropped', reason: 'empty_content' }));
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return { type: 'markdown', data: { content } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function coreButtonToQq(btn: QqOutboundButtonSpec): Record<string, unknown> {
|
|
105
|
+
const isCommand = btn.mode === 'command';
|
|
106
|
+
const action: Record<string, unknown> = {
|
|
107
|
+
type: isCommand ? 2 : 0,
|
|
108
|
+
permission: { type: 2 },
|
|
109
|
+
data: btn.payload ?? '',
|
|
110
|
+
click_limit: btn.disabled ? 0 : 10,
|
|
111
|
+
unsupport_tips: btn.disabled ? '该按钮不可用' : '',
|
|
112
|
+
};
|
|
113
|
+
if (isCommand) {
|
|
114
|
+
if (btn.command?.enter != null) action.enter = btn.command.enter;
|
|
115
|
+
if (btn.command?.reply != null) action.reply = btn.command.reply;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
id: btn.id ?? '',
|
|
119
|
+
render_data: {
|
|
120
|
+
label: btn.label ?? '',
|
|
121
|
+
visited_label: btn.label ?? '',
|
|
122
|
+
style: btn.style === 'primary' ? 1 : btn.style === 'danger' ? 2 : 0,
|
|
123
|
+
},
|
|
124
|
+
action,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** keyboard 段 → 模板键盘(data.id)或 button 行段(data.rows) */
|
|
129
|
+
function expandKeyboardSegment(seg: QqWireSegment): QqOutboundElem[] {
|
|
130
|
+
const data = seg.data ?? {};
|
|
131
|
+
if (typeof data.id === 'string' && data.id) {
|
|
132
|
+
return [{ type: 'keyboard', data: { id: data.id } }];
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(data.rows)) {
|
|
135
|
+
logger.warn(formatCompact({ op: 'qq_outbound_keyboard_dropped', reason: 'missing_rows' }));
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
const out: QqOutboundElem[] = [];
|
|
139
|
+
for (const row of data.rows) {
|
|
140
|
+
if (!Array.isArray(row)) continue;
|
|
141
|
+
out.push({
|
|
142
|
+
type: 'button',
|
|
143
|
+
data: {
|
|
144
|
+
buttons: row.map((btn) => coreButtonToQq((btn ?? {}) as QqOutboundButtonSpec)),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function toSegmentList(payload: unknown): Array<string | QqWireSegment> {
|
|
152
|
+
if (Array.isArray(payload)) return payload as Array<string | QqWireSegment>;
|
|
153
|
+
if (payload && typeof payload === 'object' && 'type' in payload) {
|
|
154
|
+
return [payload as QqWireSegment];
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function textFromUnknownSegment(seg: QqWireSegment): string {
|
|
160
|
+
const data = seg.data ?? {};
|
|
161
|
+
return data.text != null ? String(data.text) : '';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 渲染后 payload → QQ Sendable。
|
|
166
|
+
* 纯文本输入原样返回(与既有行为一致);含富媒体段时返回段数组。
|
|
167
|
+
*/
|
|
168
|
+
export function formatOutbound(payload: unknown): QqOutboundMessage {
|
|
169
|
+
if (typeof payload === 'string') return payload;
|
|
170
|
+
|
|
171
|
+
const segments = toSegmentList(payload);
|
|
172
|
+
if (segments.length === 0) {
|
|
173
|
+
return payload == null
|
|
174
|
+
? ''
|
|
175
|
+
: typeof payload === 'object'
|
|
176
|
+
? JSON.stringify(payload)
|
|
177
|
+
: String(payload);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// QQ 按钮消息必须携带 markdown(msg_type=2),keyboard 存在时文本合并为 markdown 段
|
|
181
|
+
const hasKeyboard = segments.some(
|
|
182
|
+
(item) => typeof item !== 'string' && item.type === 'keyboard',
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const parts: Array<string | QqOutboundElem> = [];
|
|
186
|
+
let textBuf = '';
|
|
187
|
+
const flushText = (): void => {
|
|
188
|
+
if (!textBuf) return;
|
|
189
|
+
parts.push(hasKeyboard
|
|
190
|
+
? { type: 'markdown', data: { content: textBuf } }
|
|
191
|
+
: textBuf);
|
|
192
|
+
textBuf = '';
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
for (const item of segments) {
|
|
196
|
+
if (typeof item === 'string') {
|
|
197
|
+
textBuf += item;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const data = item.data ?? {};
|
|
201
|
+
switch (item.type) {
|
|
202
|
+
case 'text':
|
|
203
|
+
textBuf += String(data.text ?? data.content ?? '');
|
|
204
|
+
break;
|
|
205
|
+
case 'at': {
|
|
206
|
+
const id = String(data.user_id ?? data.qq ?? data.id ?? '');
|
|
207
|
+
if (!id) break;
|
|
208
|
+
flushText();
|
|
209
|
+
parts.push({ type: 'at', data: { user_id: id } });
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'image':
|
|
213
|
+
case 'audio':
|
|
214
|
+
case 'video':
|
|
215
|
+
case 'file': {
|
|
216
|
+
flushText();
|
|
217
|
+
const media = normalizeMediaSegment(item);
|
|
218
|
+
if (media) parts.push(media);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case 'markdown': {
|
|
222
|
+
flushText();
|
|
223
|
+
const markdown = normalizeMarkdownSegment(item);
|
|
224
|
+
if (markdown) parts.push(markdown);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case 'keyboard': {
|
|
228
|
+
flushText();
|
|
229
|
+
parts.push(...expandKeyboardSegment(item));
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case 'reply': {
|
|
233
|
+
const id = data.id ?? data.event_id;
|
|
234
|
+
if (id == null || id === '') break;
|
|
235
|
+
flushText();
|
|
236
|
+
parts.push({
|
|
237
|
+
type: 'reply',
|
|
238
|
+
data: data.event_id != null
|
|
239
|
+
? { event_id: String(data.event_id) }
|
|
240
|
+
: { id: String(id) },
|
|
241
|
+
});
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
default:
|
|
245
|
+
// 未识别段:保持既有文本降级
|
|
246
|
+
textBuf += textFromUnknownSegment(item);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
flushText();
|
|
251
|
+
|
|
252
|
+
if (parts.length === 0) return '';
|
|
253
|
+
if (parts.length === 1) return parts[0]!;
|
|
254
|
+
return parts;
|
|
255
|
+
}
|