@qwen-code/channel-qqbot 0.26.4 → 0.26.5

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.
@@ -0,0 +1,957 @@
1
+ /**
2
+ * QQ group-management tools exposed to the agent as client MCP tools.
3
+ *
4
+ * The model calls these tools autonomously during a turn; the adapter owns the
5
+ * chat binding and the credentials, this module owns the schemas, argument
6
+ * validation, and the mapping from QQ error codes to text the model can act
7
+ * on. Tools marked `reserved` call an endpoint the open platform currently
8
+ * gates behind an invite-only allowlist: they are wired and callable, and
9
+ * surface the platform's refusal instead of a silent no-op.
10
+ *
11
+ * @see https://bot.q.qq.com/wiki/develop/api-v2/server-inter/group/manage/
12
+ */
13
+ const ADMIN_NOTE = 'Requires the bot to be a group admin; the platform refuses otherwise.';
14
+ const RESERVED_NOTE = 'Reserved interface: this endpoint is currently invite-only on the QQ open platform (error 11253 unless the bot app is allowlisted), so report the platform answer instead of assuming success.';
15
+ /** `groupPath` needs the chat bound to the session. */
16
+ function requireGroup(chat) {
17
+ if (!chat || !chat.isGroup) {
18
+ throw new ToolInputError('This tool only applies to a QQ group chat session.');
19
+ }
20
+ return chat;
21
+ }
22
+ /** The handler rejects a missing binding first; this narrows the type for tools that require one. */
23
+ function requireBoundChat(chat) {
24
+ if (!chat) {
25
+ throw new ToolInputError('No QQ chat is bound to this session yet, so no group action is possible.');
26
+ }
27
+ return chat;
28
+ }
29
+ function groupPath(chat, suffix) {
30
+ const group = requireGroup(chat);
31
+ return `/v2/groups/${encodePart(group.chatId)}${suffix}`;
32
+ }
33
+ function strategyPath(strategyId, suffix = '') {
34
+ return `/v2/groups/join_approval_strategy/${encodePart(strategyId)}${suffix}`;
35
+ }
36
+ function withQuery(path, params) {
37
+ const query = new URLSearchParams();
38
+ for (const [key, value] of Object.entries(params)) {
39
+ if (value !== undefined)
40
+ query.set(key, String(value));
41
+ }
42
+ const serialized = query.toString();
43
+ return serialized ? `${path}?${serialized}` : path;
44
+ }
45
+ const RECALL_TOOL = {
46
+ name: 'qq_recall_message',
47
+ description: "Recall a message in the current QQ group chat. The call requires a target: pass member_openid to recall that member's most recent message the channel still remembers from this chat, or message_id for a platform message id the operator or the channel logs supplied — the channel does not put message ids into the transcript, so ask for one instead of guessing. There is no implicit target, and the tool never recalls the bot's own messages. Member-targeted recall only reaches messages the channel actually received and still tracks: ordinary non-@ group messages arrive only when groupAllPolicy is 'keyword', 'all' or 'log', and the member must be a full member_openid, which requires allowMention (with allowMention: false the transcript carries only a truncated sender name, so only message_id works). A member_openid recall is allowed only when the turn's own sender is that member, or when the turn has no member sender (for example an operator-initiated turn). Recalling another member's message requires the bot to be a group admin. Direct chats are unsupported, because the platform only recalls the bot's own messages there.",
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ message_id: {
52
+ type: 'string',
53
+ description: 'Explicit id of a message in this group chat.',
54
+ },
55
+ member_openid: {
56
+ type: 'string',
57
+ description: "The member's openid whose most recent recorded message should be recalled.",
58
+ },
59
+ },
60
+ additionalProperties: false,
61
+ },
62
+ build(args, deps, chat, sessionId) {
63
+ const bound = requireBoundChat(chat);
64
+ // The platform recalls only the bot's own messages in a direct chat, and
65
+ // this tool never recalls those, so every direct-chat call is unsupported.
66
+ if (!bound.isGroup) {
67
+ throw new ToolInputError("qq_recall_message does not support direct chats: the platform only recalls the bot's own messages there, and this tool never recalls a message the bot sent.");
68
+ }
69
+ const explicit = readOptionalString(args, 'message_id');
70
+ const member = readOptionalString(args, 'member_openid');
71
+ let messageId = explicit;
72
+ if (!messageId) {
73
+ // There is no implicit target: a call that names neither cannot say
74
+ // which message it meant, and guessing would sooner or later destroy
75
+ // the wrong one.
76
+ if (!member) {
77
+ throw new ToolInputError("qq_recall_message needs a target: pass member_openid to recall that member's most recent message the channel still remembers, or message_id for an explicit platform message id (the channel does not make message ids visible, so that id must come from the operator or the channel logs). There is no implicit target.");
78
+ }
79
+ // A group member must not censor another member's message through the
80
+ // bot's admin token: a member-targeted recall is only allowed for the
81
+ // turn's own sender, or on a turn that has no member sender.
82
+ const requester = deps.currentSenderId(sessionId);
83
+ if (requester !== undefined && requester !== member) {
84
+ throw new ToolInputError(`qq_recall_message can only recall the sender's own message: this turn was sent by ${requester}, not ${member}. Ask the operator for an explicit message_id instead.`);
85
+ }
86
+ if (deps.memberRecallDisabled) {
87
+ throw new ToolInputError('Member-targeted recall is disabled for this channel (moderationRecallWindowMs is 0), so no message id is reachable from this turn. Do not guess one: ask the operator for the message_id and pass it explicitly.');
88
+ }
89
+ messageId = deps.recentMemberMessage(bound.chatId, member);
90
+ if (!messageId) {
91
+ throw new ToolInputError(`No message from ${member} is still tracked for recall in this chat — it may have fallen out of the per-chat history the channel keeps, or was never delivered to the bot (ordinary group messages need groupAllPolicy 'keyword', 'all' or 'log').`);
92
+ }
93
+ }
94
+ return {
95
+ method: 'DELETE',
96
+ path: `/v2/groups/${encodePart(bound.chatId)}/messages/${encodePart(messageId)}`,
97
+ target: {
98
+ chatId: bound.chatId,
99
+ messageId,
100
+ source: explicit ? 'message_id' : 'member_openid',
101
+ },
102
+ };
103
+ },
104
+ format: (_data, target) => target === undefined
105
+ ? 'Message recalled.'
106
+ : `Message recalled (${target.messageId}).`,
107
+ };
108
+ const GROUP_INFO_TOOL = {
109
+ name: 'qq_get_group_info',
110
+ description: `Get the current QQ group profile: name, description, category, tags and member count. ${RESERVED_NOTE}`,
111
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
112
+ groupOnly: true,
113
+ build: (_args, _deps, chat) => ({
114
+ method: 'GET',
115
+ path: groupPath(chat, '/info'),
116
+ }),
117
+ };
118
+ const BOT_STATE_TOOL = {
119
+ name: 'qq_get_bot_state',
120
+ description: `Get the bot's own state in the current QQ group: member role (member/admin/owner), join time, whether proactive push is allowed, and which messages the group lets the bot receive. ${RESERVED_NOTE}`,
121
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
122
+ groupOnly: true,
123
+ build: (_args, _deps, chat) => ({
124
+ method: 'GET',
125
+ path: groupPath(chat, '/bot_state'),
126
+ }),
127
+ };
128
+ const LIST_JOIN_REQUESTS_TOOL = {
129
+ name: 'qq_list_join_requests',
130
+ description: `List pending join requests for the current QQ group, newest first. ${ADMIN_NOTE}`,
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {
134
+ cursor: {
135
+ type: 'string',
136
+ description: 'Pagination cursor from a previous call; omit for page 1.',
137
+ },
138
+ limit: {
139
+ type: 'integer',
140
+ description: 'Page size, default 20, maximum 50.',
141
+ },
142
+ },
143
+ additionalProperties: false,
144
+ },
145
+ groupOnly: true,
146
+ build(args, _deps, chat) {
147
+ return {
148
+ method: 'GET',
149
+ path: withQuery(groupPath(chat, '/join_request_list'), {
150
+ cursor: readOptionalString(args, 'cursor'),
151
+ limit: readPageSize(args, 'limit', 50),
152
+ }),
153
+ };
154
+ },
155
+ };
156
+ const REVIEW_JOIN_REQUEST_TOOL = {
157
+ name: 'qq_review_join_request',
158
+ description: `Approve or decline a pending join request for the current QQ group. ${ADMIN_NOTE}`,
159
+ inputSchema: {
160
+ type: 'object',
161
+ properties: {
162
+ member_openid: {
163
+ type: 'string',
164
+ description: 'Openid of the applicant (from qq_list_join_requests).',
165
+ },
166
+ op: {
167
+ type: 'string',
168
+ enum: ['approve', 'decline'],
169
+ description: 'approve to let them in, decline to reject.',
170
+ },
171
+ join_request_id: {
172
+ type: 'string',
173
+ description: 'Join request id from qq_list_join_requests.',
174
+ },
175
+ reject_reason: {
176
+ type: 'string',
177
+ description: 'Reason shown when declining.',
178
+ },
179
+ add_to_member_blacklist: {
180
+ type: 'boolean',
181
+ description: 'Also blacklist the applicant when declining.',
182
+ },
183
+ },
184
+ required: ['member_openid', 'op'],
185
+ additionalProperties: false,
186
+ },
187
+ groupOnly: true,
188
+ build(args, _deps, chat) {
189
+ const body = {
190
+ op: readEnum(args, 'op', ['approve', 'decline']),
191
+ };
192
+ const joinRequestId = readOptionalString(args, 'join_request_id');
193
+ if (joinRequestId)
194
+ body['join_request_id'] = joinRequestId;
195
+ const rejectReason = readOptionalString(args, 'reject_reason');
196
+ if (rejectReason)
197
+ body['reject_reason'] = rejectReason;
198
+ const blacklist = readOptionalBoolean(args, 'add_to_member_blacklist');
199
+ if (blacklist !== undefined)
200
+ body['add_to_member_blacklist'] = blacklist;
201
+ return {
202
+ method: 'POST',
203
+ path: groupPath(chat, `/approval_join_request/${encodePart(readString(args, 'member_openid'))}`),
204
+ body,
205
+ };
206
+ },
207
+ format: (data) => `Join request handled. ${stringify(data)}`,
208
+ };
209
+ const GET_MUTE_STATUS_TOOL = {
210
+ name: 'qq_get_mute_status',
211
+ description: `Query the current QQ group mute state: the group-wide mute rule and every member-level mute still in effect. ${ADMIN_NOTE}`,
212
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
213
+ groupOnly: true,
214
+ build: (_args, _deps, chat) => ({
215
+ method: 'GET',
216
+ path: groupPath(chat, '/restrict_chat_setting'),
217
+ }),
218
+ };
219
+ const SET_MUTE_TOOL = {
220
+ name: 'qq_set_mute',
221
+ description: `Mute or unmute group members in the current QQ group. ${ADMIN_NOTE} Max mute duration is 30 days; only ordinary members can be muted (not the owner, other admins or bots).`,
222
+ inputSchema: {
223
+ type: 'object',
224
+ properties: {
225
+ member_openid: {
226
+ type: 'string',
227
+ description: 'Single member to mute or unmute.',
228
+ },
229
+ members: {
230
+ type: 'array',
231
+ description: 'Bulk form, at most 20 entries, each {member_openid, op, mute_expire_at}. Both member_openid and op are required on every entry.',
232
+ items: {
233
+ type: 'object',
234
+ properties: {
235
+ member_openid: { type: 'string' },
236
+ op: { type: 'string', enum: ['add', 'update', 'del'] },
237
+ mute_expire_at: {
238
+ type: 'string',
239
+ description: 'RFC3339 expiry, e.g. 2026-08-05T11:23:05+08:00. For op=del an empty string lifts the mute now.',
240
+ },
241
+ },
242
+ required: ['member_openid', 'op'],
243
+ additionalProperties: false,
244
+ },
245
+ },
246
+ op: {
247
+ type: 'string',
248
+ enum: ['add', 'update', 'del'],
249
+ description: 'Operation for the single-member form: add mutes, update changes the expiry, del lifts the mute. Required.',
250
+ },
251
+ mute_expire_at: {
252
+ type: 'string',
253
+ description: 'RFC3339 expiry for the single-member form, e.g. 2026-08-05T11:23:05+08:00. For op=del an empty string lifts the mute now.',
254
+ },
255
+ },
256
+ additionalProperties: false,
257
+ },
258
+ groupOnly: true,
259
+ memberTarget: true,
260
+ build(args, _deps, chat) {
261
+ const members = readMembers(args);
262
+ if (members.length === 0) {
263
+ throw new ToolInputError('member_openid or members is required.');
264
+ }
265
+ if (members.length > 20) {
266
+ throw new ToolInputError('At most 20 members per call.');
267
+ }
268
+ return {
269
+ method: 'POST',
270
+ path: groupPath(chat, '/restrict_chat_setting'),
271
+ body: { members },
272
+ summary: members
273
+ .map((member) => `${member.op} ${member.member_openid}`)
274
+ .join(', '),
275
+ };
276
+ },
277
+ format: (_data, _target, request) => `Mute state updated (${request.summary ?? 'no member'}).`,
278
+ };
279
+ const LIST_STRATEGIES_TOOL = {
280
+ name: 'qq_list_join_approval_strategies',
281
+ description: 'List the bot-level auto-join-approval strategies currently in effect (bot-wide, not per group), newest first.',
282
+ inputSchema: {
283
+ type: 'object',
284
+ properties: {
285
+ cursor: {
286
+ type: 'string',
287
+ description: 'Pagination cursor from a previous call.',
288
+ },
289
+ limit: {
290
+ type: 'integer',
291
+ description: 'Page size, default 20, maximum 50.',
292
+ },
293
+ },
294
+ additionalProperties: false,
295
+ },
296
+ botLevel: true,
297
+ build(args) {
298
+ return {
299
+ method: 'GET',
300
+ path: withQuery('/v2/groups/join_approval_strategy', {
301
+ cursor: readOptionalString(args, 'cursor'),
302
+ limit: readPageSize(args, 'limit', 50),
303
+ }),
304
+ };
305
+ },
306
+ };
307
+ const CREATE_STRATEGY_TOOL = {
308
+ name: 'qq_create_join_approval_strategy',
309
+ description: 'Create a bot-level auto-join-approval strategy: join requests from whitelisted QQ numbers are approved automatically once the strategy runs. The strategy is created enabled-free (default on) but only takes effect while the bot is a group admin. Provide exactly one of group_openids / group_ids. A bot may hold at most 20 strategies. Whitelist numbers are added separately with qq_set_join_approval_whitelist.',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ group_openids: {
314
+ type: 'array',
315
+ items: { type: 'string' },
316
+ description: 'Group openids the strategy covers, at most 100. Mutually exclusive with group_ids.',
317
+ },
318
+ group_ids: {
319
+ type: 'array',
320
+ items: { type: 'string' },
321
+ description: 'QQ group numbers the strategy covers (strings, at most 100). Mutually exclusive with group_openids.',
322
+ },
323
+ is_enable: {
324
+ type: 'string',
325
+ enum: ['on', 'off'],
326
+ description: 'Default on.',
327
+ },
328
+ expire_at: {
329
+ type: 'string',
330
+ description: 'RFC3339 expiry; defaults to one year from now.',
331
+ },
332
+ remark: {
333
+ type: 'string',
334
+ description: 'Human-readable note, at most 255 characters.',
335
+ },
336
+ },
337
+ additionalProperties: false,
338
+ },
339
+ botLevel: true,
340
+ build(args) {
341
+ const openids = readStringArray(args, 'group_openids');
342
+ const groupIds = readStringArray(args, 'group_ids');
343
+ if ((openids === undefined) === (groupIds === undefined)) {
344
+ throw new ToolInputError('Pass exactly one of group_openids or group_ids.');
345
+ }
346
+ const body = {};
347
+ assertMaxEntries(openids, 100, 'group_openids');
348
+ assertMaxEntries(groupIds, 100, 'group_ids');
349
+ if (openids)
350
+ body['group_openids'] = openids;
351
+ if (groupIds)
352
+ body['group_ids'] = groupIds.map(toGroupNumber);
353
+ const isEnable = readOptionalEnum(args, 'is_enable', ['on', 'off']);
354
+ if (isEnable)
355
+ body['is_enable'] = isEnable;
356
+ const expireAt = readOptionalString(args, 'expire_at');
357
+ if (expireAt)
358
+ body['expire_at'] = expireAt;
359
+ const remark = readOptionalString(args, 'remark');
360
+ assertMaxChars(remark, 255, 'remark');
361
+ if (remark)
362
+ body['remark'] = remark;
363
+ return { method: 'POST', path: '/v2/groups/join_approval_strategy', body };
364
+ },
365
+ format: (data) => `Strategy created. ${stringify(data)}`,
366
+ };
367
+ const UPDATE_STRATEGY_TOOL = {
368
+ name: 'qq_update_join_approval_strategy',
369
+ description: 'Update a bot-level auto-join-approval strategy: enable/disable it, change its expiry, add or remove covered groups, or change the remark. Group identifiers must use the same form the strategy was created with.',
370
+ inputSchema: {
371
+ type: 'object',
372
+ properties: {
373
+ strategy_id: { type: 'string', description: 'Strategy id.' },
374
+ is_enable: { type: 'string', enum: ['on', 'off'] },
375
+ expire_at: { type: 'string', description: 'RFC3339 expiry.' },
376
+ group_action: {
377
+ type: 'object',
378
+ description: 'Add or remove covered groups.',
379
+ properties: {
380
+ op: { type: 'string', enum: ['add', 'del'] },
381
+ group_openids: { type: 'array', items: { type: 'string' } },
382
+ group_ids: { type: 'array', items: { type: 'string' } },
383
+ },
384
+ required: ['op'],
385
+ },
386
+ remark: { type: 'string' },
387
+ },
388
+ required: ['strategy_id'],
389
+ additionalProperties: false,
390
+ },
391
+ botLevel: true,
392
+ build(args) {
393
+ const body = {};
394
+ const isEnable = readOptionalEnum(args, 'is_enable', ['on', 'off']);
395
+ if (isEnable)
396
+ body['is_enable'] = isEnable;
397
+ const expireAt = readOptionalString(args, 'expire_at');
398
+ if (expireAt)
399
+ body['expire_at'] = expireAt;
400
+ const remark = readOptionalString(args, 'remark');
401
+ assertMaxChars(remark, 255, 'remark');
402
+ if (remark)
403
+ body['remark'] = remark;
404
+ const action = readGroupAction(args);
405
+ if (action)
406
+ body['group_action'] = action;
407
+ if (Object.keys(body).length === 0) {
408
+ throw new ToolInputError('Provide at least one of is_enable, expire_at, group_action or remark.');
409
+ }
410
+ return {
411
+ method: 'PATCH',
412
+ path: strategyPath(readString(args, 'strategy_id')),
413
+ body,
414
+ };
415
+ },
416
+ format: (data) => `Strategy updated. ${stringify(data)}`,
417
+ };
418
+ const DELETE_STRATEGY_TOOL = {
419
+ name: 'qq_delete_join_approval_strategy',
420
+ description: 'Delete a bot-level auto-join-approval strategy.',
421
+ inputSchema: {
422
+ type: 'object',
423
+ properties: {
424
+ strategy_id: { type: 'string', description: 'Strategy id.' },
425
+ },
426
+ required: ['strategy_id'],
427
+ additionalProperties: false,
428
+ },
429
+ botLevel: true,
430
+ build(args) {
431
+ return {
432
+ method: 'DELETE',
433
+ path: strategyPath(readString(args, 'strategy_id')),
434
+ };
435
+ },
436
+ format: () => 'Strategy deleted.',
437
+ };
438
+ const EXECUTE_STRATEGY_TOOL = {
439
+ name: 'qq_execute_join_approval_strategy',
440
+ description: 'Run a bot-level auto-join-approval strategy now: it scans every covered group and approves pending join requests whose QQ number is whitelisted. Asynchronous, roughly 10 minutes.',
441
+ inputSchema: {
442
+ type: 'object',
443
+ properties: {
444
+ strategy_id: { type: 'string', description: 'Strategy id.' },
445
+ },
446
+ required: ['strategy_id'],
447
+ additionalProperties: false,
448
+ },
449
+ botLevel: true,
450
+ build(args) {
451
+ return {
452
+ method: 'POST',
453
+ path: strategyPath(readString(args, 'strategy_id'), '/execute'),
454
+ };
455
+ },
456
+ format: () => 'Strategy execution started (asynchronous, about 10 minutes).',
457
+ };
458
+ const SET_WHITELIST_TOOL = {
459
+ name: 'qq_set_join_approval_whitelist',
460
+ description: 'Add or remove whitelisted QQ numbers on a bot-level auto-join-approval strategy. At most 10000 numbers per call; a strategy holds at most 100000. Numbers must be strings to avoid precision loss. There is no API to read the numbers back — only the count.',
461
+ inputSchema: {
462
+ type: 'object',
463
+ properties: {
464
+ strategy_id: { type: 'string', description: 'Strategy id.' },
465
+ op: { type: 'string', enum: ['add', 'del'] },
466
+ whitelist_users: {
467
+ type: 'array',
468
+ items: { type: 'string' },
469
+ description: 'QQ numbers as strings, at most 10000 per call.',
470
+ },
471
+ },
472
+ required: ['strategy_id', 'op', 'whitelist_users'],
473
+ additionalProperties: false,
474
+ },
475
+ botLevel: true,
476
+ build(args) {
477
+ const users = readStringArray(args, 'whitelist_users') ?? [];
478
+ if (users.length === 0) {
479
+ throw new ToolInputError('whitelist_users must not be empty.');
480
+ }
481
+ if (users.length > 10000) {
482
+ throw new ToolInputError('At most 10000 numbers per call.');
483
+ }
484
+ return {
485
+ method: 'POST',
486
+ path: strategyPath(readString(args, 'strategy_id'), '/whitelist_users'),
487
+ body: {
488
+ op: readEnum(args, 'op', ['add', 'del']),
489
+ whitelist_users: users.map(toQqNumber),
490
+ },
491
+ };
492
+ },
493
+ format: (data) => `Whitelist updated. ${stringify(data)}`,
494
+ };
495
+ const LIST_MEMBERS_TOOL = {
496
+ name: 'qq_list_group_members',
497
+ description: `List group members, up to 30 per page. ${RESERVED_NOTE}`,
498
+ inputSchema: {
499
+ type: 'object',
500
+ properties: {
501
+ cursor: { type: 'string', description: 'Pagination cursor.' },
502
+ },
503
+ additionalProperties: false,
504
+ },
505
+ groupOnly: true,
506
+ build(args, _deps, chat) {
507
+ return {
508
+ method: 'GET',
509
+ path: withQuery(groupPath(chat, '/members'), {
510
+ cursor: readOptionalString(args, 'cursor'),
511
+ }),
512
+ };
513
+ },
514
+ };
515
+ const GET_MEMBER_TOOL = {
516
+ name: 'qq_get_group_member',
517
+ description: `Get one group member's details (nickname, role, join time). ${RESERVED_NOTE}`,
518
+ inputSchema: {
519
+ type: 'object',
520
+ properties: {
521
+ member_openid: { type: 'string', description: "The member's openid." },
522
+ },
523
+ required: ['member_openid'],
524
+ additionalProperties: false,
525
+ },
526
+ groupOnly: true,
527
+ build(args, _deps, chat) {
528
+ return {
529
+ method: 'GET',
530
+ path: groupPath(chat, `/members/${encodePart(readString(args, 'member_openid'))}`),
531
+ };
532
+ },
533
+ };
534
+ const KICK_TOOL = {
535
+ name: 'qq_kick_member',
536
+ description: `Remove one or more members from the current QQ group, optionally blacklisting them. ${RESERVED_NOTE}`,
537
+ inputSchema: {
538
+ type: 'object',
539
+ properties: {
540
+ member_openid: {
541
+ type: 'string',
542
+ description: 'The group member to remove.',
543
+ },
544
+ member_openids: {
545
+ type: 'array',
546
+ items: { type: 'string' },
547
+ description: 'Bulk form, at most 20 members. Use instead of member_openid.',
548
+ },
549
+ add_to_member_blacklist: {
550
+ type: 'boolean',
551
+ description: 'Also add them to the group blacklist. Default false.',
552
+ },
553
+ },
554
+ additionalProperties: false,
555
+ },
556
+ groupOnly: true,
557
+ memberTarget: true,
558
+ build(args, _deps, chat) {
559
+ const single = readOptionalString(args, 'member_openid');
560
+ const bulk = readStringArray(args, 'member_openids');
561
+ // Reject ambiguity instead of ranking it: silently dropping a named
562
+ // member would remove someone other than what the model asked for.
563
+ if (single !== undefined && bulk !== undefined && bulk.length > 0) {
564
+ throw new ToolInputError('Pass either member_openid or member_openids, not both.');
565
+ }
566
+ // An empty bulk array is absence, so a supplied member_openid still resolves.
567
+ const memberOpenids = bulk !== undefined && bulk.length > 0 ? bulk : single ? [single] : [];
568
+ if (memberOpenids.length === 0) {
569
+ throw new ToolInputError('member_openid or member_openids is required.');
570
+ }
571
+ if (memberOpenids.length > 20) {
572
+ throw new ToolInputError('At most 20 members per call.');
573
+ }
574
+ const addToBlacklist = readOptionalBoolean(args, 'add_to_member_blacklist');
575
+ return {
576
+ method: 'POST',
577
+ path: groupPath(chat, '/batch_remove_members'),
578
+ body: {
579
+ member_openids: memberOpenids,
580
+ ...(addToBlacklist === undefined
581
+ ? {}
582
+ : { add_to_member_blacklist: addToBlacklist }),
583
+ },
584
+ summary: memberOpenids.join(', '),
585
+ };
586
+ },
587
+ format: (data) => `Platform response: ${stringify(data)}`,
588
+ };
589
+ const LIST_BLACKLIST_TOOL = {
590
+ name: 'qq_list_member_blacklist',
591
+ description: `List the group blacklist, up to 100 per page. ${RESERVED_NOTE}`,
592
+ inputSchema: {
593
+ type: 'object',
594
+ properties: {
595
+ cursor: { type: 'string', description: 'Pagination cursor.' },
596
+ limit: {
597
+ type: 'integer',
598
+ description: 'Page size, default 20, maximum 100.',
599
+ },
600
+ },
601
+ additionalProperties: false,
602
+ },
603
+ groupOnly: true,
604
+ build(args, _deps, chat) {
605
+ return {
606
+ method: 'GET',
607
+ path: withQuery(groupPath(chat, '/member_blacklist'), {
608
+ cursor: readOptionalString(args, 'cursor'),
609
+ limit: readPageSize(args, 'limit', 100),
610
+ }),
611
+ };
612
+ },
613
+ };
614
+ const UPDATE_BLACKLIST_TOOL = {
615
+ name: 'qq_update_member_blacklist',
616
+ description: `Add members to or remove them from the group blacklist. A member who is still in the group cannot be blacklisted — remove them first. ${RESERVED_NOTE}`,
617
+ inputSchema: {
618
+ type: 'object',
619
+ properties: {
620
+ op: { type: 'string', enum: ['add', 'del'] },
621
+ member_openids: {
622
+ type: 'array',
623
+ items: { type: 'string' },
624
+ description: 'At most 20 openids.',
625
+ },
626
+ },
627
+ required: ['op', 'member_openids'],
628
+ additionalProperties: false,
629
+ },
630
+ groupOnly: true,
631
+ build(args, _deps, chat) {
632
+ const openids = readStringArray(args, 'member_openids') ?? [];
633
+ if (openids.length === 0) {
634
+ throw new ToolInputError('member_openids must not be empty.');
635
+ }
636
+ if (openids.length > 20) {
637
+ throw new ToolInputError('At most 20 members per call.');
638
+ }
639
+ return {
640
+ method: 'POST',
641
+ path: groupPath(chat, '/member_blacklist'),
642
+ body: {
643
+ op: readEnum(args, 'op', ['add', 'del']),
644
+ member_openids: openids,
645
+ },
646
+ };
647
+ },
648
+ format: (data) => `Platform response: ${stringify(data)}`,
649
+ };
650
+ const TOOL_SPECS = [
651
+ RECALL_TOOL,
652
+ GROUP_INFO_TOOL,
653
+ BOT_STATE_TOOL,
654
+ LIST_JOIN_REQUESTS_TOOL,
655
+ REVIEW_JOIN_REQUEST_TOOL,
656
+ GET_MUTE_STATUS_TOOL,
657
+ SET_MUTE_TOOL,
658
+ LIST_STRATEGIES_TOOL,
659
+ CREATE_STRATEGY_TOOL,
660
+ UPDATE_STRATEGY_TOOL,
661
+ DELETE_STRATEGY_TOOL,
662
+ EXECUTE_STRATEGY_TOOL,
663
+ SET_WHITELIST_TOOL,
664
+ LIST_MEMBERS_TOOL,
665
+ GET_MEMBER_TOOL,
666
+ KICK_TOOL,
667
+ LIST_BLACKLIST_TOOL,
668
+ UPDATE_BLACKLIST_TOOL,
669
+ ];
670
+ export function buildModerationTools(deps) {
671
+ return TOOL_SPECS.map((spec) => ({
672
+ name: spec.name,
673
+ description: spec.description,
674
+ inputSchema: spec.inputSchema,
675
+ async handler(args, context) {
676
+ const fail = (text) => ({
677
+ text,
678
+ isError: true,
679
+ });
680
+ // The schemas declare additionalProperties: false, but that is advisory
681
+ // to the model and a renamed key must not be dropped silently: dropping
682
+ // it would run something other than what the model named (a kick without
683
+ // the blacklist, a mute that never happens). Reject it instead.
684
+ const accepted = Object.keys((spec.inputSchema['properties'] ?? {}));
685
+ const unknown = Object.keys(args).filter((key) => !accepted.includes(key));
686
+ if (unknown.length > 0) {
687
+ return fail(`Unrecognized argument${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}. ${spec.name} accepts only ${listArgs(accepted)}.`);
688
+ }
689
+ const chat = deps.resolveChat(context.sessionId);
690
+ if (!chat && !spec.botLevel) {
691
+ return fail('No QQ chat is bound to this session yet, so no group action is possible.');
692
+ }
693
+ if (spec.groupOnly && !chat?.isGroup) {
694
+ return fail(`${spec.name} only applies to a group chat session.`);
695
+ }
696
+ let request;
697
+ try {
698
+ request = spec.build(args, deps, chat, context.sessionId);
699
+ }
700
+ catch (error) {
701
+ if (error instanceof ToolInputError)
702
+ return fail(error.message);
703
+ throw error;
704
+ }
705
+ const result = await deps.request({
706
+ method: request.method,
707
+ path: request.path,
708
+ ...(request.body === undefined ? {} : { body: request.body }),
709
+ });
710
+ if (!result.ok) {
711
+ const text = describeFailure(spec, result, request.target);
712
+ deps.log(`${spec.name} failed: ${text}`);
713
+ if (request.target !== undefined && result.status === 0) {
714
+ // No platform verdict: the DELETE may or may not have landed, but
715
+ // the id is unusable either way. Leave the entry only on a
716
+ // platform-reported failure, where retrying the same target is safe.
717
+ deps.dropRecentMessage(request.target.chatId, request.target.messageId);
718
+ }
719
+ return fail(text);
720
+ }
721
+ const summary = request.target === undefined
722
+ ? request.summary
723
+ : `target=${request.target.chatId}/${request.target.messageId} via=${request.target.source}`;
724
+ deps.log(summary === undefined
725
+ ? `${spec.name} ok (HTTP ${result.status})`
726
+ : `${spec.name} ok (HTTP ${result.status}) ${summary}`);
727
+ if (request.target !== undefined) {
728
+ // A successful recall destroys the target, so a repeat member-targeted
729
+ // call must resolve the previous message rather than this one.
730
+ deps.dropRecentMessage(request.target.chatId, request.target.messageId);
731
+ }
732
+ const formatted = spec.format?.(result.data, request.target, request) ??
733
+ stringify(result.data);
734
+ return formatted;
735
+ },
736
+ }));
737
+ }
738
+ /** Argument errors are reported to the model, not thrown at the bridge. */
739
+ class ToolInputError extends Error {
740
+ }
741
+ /** Join accepted argument names for the unrecognized-key error. */
742
+ function listArgs(keys) {
743
+ if (keys.length === 0)
744
+ return 'no arguments';
745
+ if (keys.length === 1)
746
+ return keys[0];
747
+ return `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`;
748
+ }
749
+ function describeFailure(spec, result, target) {
750
+ const tool = spec.name;
751
+ switch (result.code) {
752
+ case 11253:
753
+ return `${tool}: the platform refused (11253 ${result.message}) — this endpoint is allowlist-only for the bot app.`;
754
+ case 40062003:
755
+ return `${tool}: the platform refused (40062003 ${result.message}) — no permission for this operation: the bot must be a group admin${spec.memberTarget === true ? ', and the target must be an ordinary member (not the owner, an admin or a bot)' : ''}.`;
756
+ case 40064004:
757
+ return `${tool}: the platform refused (40064004 ${result.message}) — the message can no longer be recalled.`;
758
+ case 50065001:
759
+ return `${tool}: the platform failed to recall the message (50065001 ${result.message}); it is safe to retry${target === undefined ? '' : ` with message_id="${target.messageId}"`} so the same message is recalled.`;
760
+ case 40061001:
761
+ return `${tool}: the platform rejected the parameters (40061001 ${result.message}).`;
762
+ default:
763
+ return result.status === 0
764
+ ? `${tool}: the platform call did not complete: ${result.message}${target === undefined ? '' : ` — message_id="${target.messageId}" may or may not have been recalled; retry by message_id instead of by member.`}`
765
+ : `${tool}: platform call failed (HTTP ${result.status}${result.code === undefined ? '' : `, code ${result.code}`}): ${result.message}`;
766
+ }
767
+ }
768
+ function readMembers(args) {
769
+ const bulk = args['members'];
770
+ // A present-but-wrong-typed `members` must not fall through to the
771
+ // single-member form: that would act on a different member than the one the
772
+ // model named.
773
+ if (bulk !== undefined && bulk !== null && !Array.isArray(bulk)) {
774
+ throw new ToolInputError('members must be an array of objects.');
775
+ }
776
+ if (Array.isArray(bulk)) {
777
+ return bulk.map((entry, index) => {
778
+ if (typeof entry !== 'object' || entry === null) {
779
+ throw new ToolInputError(`members[${index}] must be an object.`);
780
+ }
781
+ const record = entry;
782
+ const op = readOptionalEnum(record, 'op', ['add', 'update', 'del']);
783
+ if (op === undefined) {
784
+ throw new ToolInputError(`members[${index}].op is required (add, update or del).`);
785
+ }
786
+ const expireAt = readOptionalExpiry(record, 'mute_expire_at');
787
+ return {
788
+ op,
789
+ member_openid: readString(record, 'member_openid'),
790
+ ...(expireAt === undefined ? {} : { mute_expire_at: expireAt }),
791
+ };
792
+ });
793
+ }
794
+ const single = readOptionalString(args, 'member_openid');
795
+ if (!single)
796
+ return [];
797
+ // The single-member form requires the operation: defaulting a missing op to
798
+ // the destructive 'add' would mute a member the model meant to unmute.
799
+ const op = readEnum(args, 'op', ['add', 'update', 'del']);
800
+ const expireAt = readOptionalExpiry(args, 'mute_expire_at');
801
+ return [
802
+ {
803
+ op,
804
+ member_openid: single,
805
+ ...(expireAt === undefined ? {} : { mute_expire_at: expireAt }),
806
+ },
807
+ ];
808
+ }
809
+ function readGroupAction(args) {
810
+ const raw = args['group_action'];
811
+ if (raw === undefined || raw === null)
812
+ return undefined;
813
+ if (typeof raw !== 'object') {
814
+ throw new ToolInputError('group_action must be an object.');
815
+ }
816
+ const record = raw;
817
+ const openids = readStringArray(record, 'group_openids');
818
+ const groupIds = readStringArray(record, 'group_ids');
819
+ if ((openids === undefined) === (groupIds === undefined)) {
820
+ throw new ToolInputError('group_action needs exactly one of group_openids or group_ids.');
821
+ }
822
+ assertMaxEntries(openids, 100, 'group_action.group_openids');
823
+ assertMaxEntries(groupIds, 100, 'group_action.group_ids');
824
+ return {
825
+ op: readEnum(record, 'op', ['add', 'del']),
826
+ ...(openids ? { group_openids: openids } : {}),
827
+ ...(groupIds ? { group_ids: groupIds.map(toGroupNumber) } : {}),
828
+ };
829
+ }
830
+ /**
831
+ * QQ group numbers go over the wire as uint64, which JSON carries as a number.
832
+ * Capping the digit count keeps the value inside Number.MAX_SAFE_INTEGER so the
833
+ * conversion cannot silently round a group number to a different group.
834
+ */
835
+ function toGroupNumber(value) {
836
+ if (!/^\d{1,15}$/.test(value)) {
837
+ throw new ToolInputError(`${value} is not a QQ group number.`);
838
+ }
839
+ return Number(value);
840
+ }
841
+ /** Whitelist entries are documented as strings to avoid precision loss. */
842
+ function toQqNumber(value) {
843
+ if (!/^\d{1,20}$/.test(value)) {
844
+ throw new ToolInputError(`${value} is not a QQ number.`);
845
+ }
846
+ return value;
847
+ }
848
+ function readString(args, key) {
849
+ const value = readOptionalString(args, key);
850
+ if (value === undefined) {
851
+ throw new ToolInputError(`${key} is required.`);
852
+ }
853
+ return value;
854
+ }
855
+ function readOptionalString(args, key) {
856
+ const value = args[key];
857
+ if (value === undefined || value === null || value === '')
858
+ return undefined;
859
+ if (typeof value !== 'string') {
860
+ throw new ToolInputError(`${key} must be a string.`);
861
+ }
862
+ return value.trim() === '' ? undefined : value.trim();
863
+ }
864
+ /**
865
+ * Read `mute_expire_at`: an explicit empty string is meaningful ("lift the
866
+ * mute now" for op=del), so it must reach the platform instead of being
867
+ * collapsed into an omitted field.
868
+ */
869
+ function readOptionalExpiry(args, key) {
870
+ if (args[key] === '')
871
+ return '';
872
+ return readOptionalString(args, key);
873
+ }
874
+ function readStringArray(args, key) {
875
+ const value = args[key];
876
+ if (value === undefined || value === null)
877
+ return undefined;
878
+ if (!Array.isArray(value)) {
879
+ throw new ToolInputError(`${key} must be an array of strings.`);
880
+ }
881
+ return value.map((item) => {
882
+ if (typeof item !== 'string' || item.trim().length === 0) {
883
+ throw new ToolInputError(`${key} entries must be non-empty strings.`);
884
+ }
885
+ return item.trim();
886
+ });
887
+ }
888
+ function readOptionalBoolean(args, key) {
889
+ const value = args[key];
890
+ if (value === undefined || value === null)
891
+ return undefined;
892
+ if (typeof value !== 'boolean') {
893
+ throw new ToolInputError(`${key} must be a boolean.`);
894
+ }
895
+ return value;
896
+ }
897
+ function readOptionalInteger(args, key) {
898
+ const value = args[key];
899
+ if (value === undefined || value === null)
900
+ return undefined;
901
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
902
+ throw new ToolInputError(`${key} must be an integer.`);
903
+ }
904
+ return value;
905
+ }
906
+ /** Page sizes the platform caps; rejecting them here saves a round trip. */
907
+ function readPageSize(args, key, max) {
908
+ const value = readOptionalInteger(args, key);
909
+ if (value === undefined)
910
+ return undefined;
911
+ if (value < 1 || value > max) {
912
+ throw new ToolInputError(`${key} must be between 1 and ${max}.`);
913
+ }
914
+ return value;
915
+ }
916
+ function assertMaxEntries(values, max, key) {
917
+ if (values && values.length > max) {
918
+ throw new ToolInputError(`${key} accepts at most ${max} entries.`);
919
+ }
920
+ }
921
+ function assertMaxChars(value, max, key) {
922
+ if (value !== undefined && [...value].length > max) {
923
+ throw new ToolInputError(`${key} accepts at most ${max} characters.`);
924
+ }
925
+ }
926
+ function readEnum(args, key, allowed) {
927
+ const value = readOptionalEnum(args, key, allowed);
928
+ if (value === undefined) {
929
+ throw new ToolInputError(`${key} is required.`);
930
+ }
931
+ return value;
932
+ }
933
+ function readOptionalEnum(args, key, allowed) {
934
+ const value = readOptionalString(args, key);
935
+ if (value === undefined)
936
+ return undefined;
937
+ if (!allowed.includes(value)) {
938
+ throw new ToolInputError(`${key} must be one of: ${allowed.join(', ')}.`);
939
+ }
940
+ return value;
941
+ }
942
+ function encodePart(value) {
943
+ return encodeURIComponent(value);
944
+ }
945
+ function stringify(data) {
946
+ if (data === undefined)
947
+ return '{}';
948
+ if (typeof data === 'string')
949
+ return data;
950
+ try {
951
+ return JSON.stringify(data);
952
+ }
953
+ catch {
954
+ return String(data);
955
+ }
956
+ }
957
+ //# sourceMappingURL=moderation-tools.js.map