@zhin.js/adapter-qq 1.1.14 → 1.1.16
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 +53 -0
- package/README.md +31 -1
- package/agents/qq/messages.js +24 -0
- package/agents/qq/messages.ts +24 -0
- package/agents/qq/skills/qq/SKILL.md +16 -10
- package/agents/qq/skills/qq/tools/add_role/index.js +3 -1
- package/agents/qq/skills/qq/tools/add_role/index.ts +3 -1
- package/agents/qq/skills/qq/tools/channel_info/index.js +1 -0
- package/agents/qq/skills/qq/tools/channel_info/index.ts +1 -0
- package/agents/qq/skills/qq/tools/create_role/index.js +3 -1
- package/agents/qq/skills/qq/tools/create_role/index.ts +3 -1
- package/agents/qq/skills/qq/tools/list_channels/index.js +1 -0
- package/agents/qq/skills/qq/tools/list_channels/index.ts +1 -0
- package/agents/qq/skills/qq/tools/list_roles/index.js +1 -0
- package/agents/qq/skills/qq/tools/list_roles/index.ts +1 -0
- package/agents/qq/skills/qq/tools/member_detail/index.js +1 -0
- package/agents/qq/skills/qq/tools/member_detail/index.ts +1 -0
- package/agents/qq/skills/qq/tools/remove_role/index.js +3 -1
- package/agents/qq/skills/qq/tools/remove_role/index.ts +3 -1
- package/agents/qq/skills/qq-directory/SKILL.md +14 -0
- package/commands/qq/endpoint/cancel/index.js +6 -5
- package/commands/qq/endpoint/cancel/index.ts +5 -5
- package/commands/qq/endpoint/definition.js +32 -30
- package/commands/qq/endpoint/definition.ts +36 -31
- package/lib/endpoint.js +28 -36
- package/lib/i18n.d.ts +1 -0
- package/lib/i18n.js +72 -0
- package/lib/passive-reply.d.ts +61 -0
- package/lib/passive-reply.js +122 -0
- package/lib/qq-bind-flow.d.ts +2 -0
- package/lib/qq-bind-flow.js +2 -1
- package/lib/qq-bot-kind-prompt.d.ts +5 -6
- package/lib/qq-bot-kind-prompt.js +26 -26
- package/lib/qq-runtime-state.d.ts +6 -0
- package/lib/qq-send.d.ts +28 -0
- package/lib/qq-send.js +109 -0
- package/lib/webhook.js +4 -4
- package/lib/ws.d.ts +10 -4
- package/lib/ws.js +4 -4
- package/middlewares/bot-kind-reply/index.js +6 -5
- package/middlewares/bot-kind-reply/index.ts +6 -5
- package/package.json +18 -18
- package/schema.json +59 -9
- package/src/endpoint.ts +26 -34
- package/src/i18n.ts +73 -0
- package/src/passive-reply.ts +159 -0
- package/src/qq-bind-flow.ts +4 -1
- package/src/qq-bot-kind-prompt.ts +22 -37
- package/src/qq-runtime-state.ts +6 -0
- package/src/qq-send.ts +138 -0
- package/src/webhook.ts +4 -4
- package/src/ws.ts +15 -8
- /package/agents/qq/skills/{qq → qq-directory}/tools/list_guilds/index.js +0 -0
- /package/agents/qq/skills/{qq → qq-directory}/tools/list_guilds/index.ts +0 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { resolveLocale, type Locale } from 'zhin.js/i18n';
|
|
2
|
+
import { messages as t } from '../../../src/i18n.js';
|
|
1
3
|
/**
|
|
2
4
|
* `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
|
|
3
5
|
* 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
|
|
@@ -19,7 +21,7 @@ import {
|
|
|
19
21
|
type EndpointConfigurationStore,
|
|
20
22
|
} from 'zhin.js/adapter';
|
|
21
23
|
import { defineCommand } from 'zhin.js/command';
|
|
22
|
-
import {
|
|
24
|
+
import { qqBotKindPrompt, qqCommandSessionKey } from '../../../src/qq-bot-kind-prompt.js';
|
|
23
25
|
import { startQqBindFlow } from '../../../src/qq-bind-flow.js';
|
|
24
26
|
import { defaultQqEndpointIntentFields, type QqBotKind } from '../../../src/qq-intents.js';
|
|
25
27
|
import {
|
|
@@ -50,10 +52,10 @@ export function extractQqCommandReply(input: unknown): QqCommandReply {
|
|
|
50
52
|
return extractEndpointCommandReply(input);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
function busyFooter(state: QqRuntimeState): string | undefined {
|
|
54
|
-
if (state.bindFlow) return '
|
|
55
|
+
function busyFooter(state: QqRuntimeState, locale?: Locale): string | undefined {
|
|
56
|
+
if (state.bindFlow) return t(locale, 'bindingBusy') + (state.bindFlow.cancelCommand ? `\n${t(locale, 'cancelHint', { command: state.bindFlow.cancelCommand })}` : '');
|
|
55
57
|
if (state.pendingBotKind) {
|
|
56
|
-
return
|
|
58
|
+
return t(locale, 'kindBusy', { id: state.pendingBotKind.endpointId });
|
|
57
59
|
}
|
|
58
60
|
return undefined;
|
|
59
61
|
}
|
|
@@ -62,11 +64,12 @@ function busyFooter(state: QqRuntimeState): string | undefined {
|
|
|
62
64
|
export async function runQqEndpointList(
|
|
63
65
|
state: QqRuntimeState,
|
|
64
66
|
store: EndpointConfigurationStore,
|
|
67
|
+
locale?: Locale,
|
|
65
68
|
): Promise<string> {
|
|
66
69
|
return formatEndpointList(qqEndpointListSpec, {
|
|
67
70
|
running: state.endpoints.values(),
|
|
68
71
|
configured: await store.list('qq'),
|
|
69
|
-
footer: busyFooter(state),
|
|
72
|
+
footer: busyFooter(state, locale), locale,
|
|
70
73
|
});
|
|
71
74
|
}
|
|
72
75
|
|
|
@@ -81,6 +84,7 @@ async function writeEndpointWithBotKind(
|
|
|
81
84
|
botKind: QqBotKind,
|
|
82
85
|
store: EndpointConfigurationStore,
|
|
83
86
|
): Promise<string> {
|
|
87
|
+
const locale = pending.locale;
|
|
84
88
|
const appidKey = buildEndpointEnvKey('qq', pending.endpointId, 'appid');
|
|
85
89
|
const secretKey = buildEndpointEnvKey('qq', pending.endpointId, 'secret');
|
|
86
90
|
const intentFields = defaultQqEndpointIntentFields(botKind);
|
|
@@ -98,11 +102,7 @@ async function writeEndpointWithBotKind(
|
|
|
98
102
|
[secretKey]: pending.appSecret,
|
|
99
103
|
},
|
|
100
104
|
});
|
|
101
|
-
return (
|
|
102
|
-
`✅ 已按 botKind=${intentFields.botKind} 将 endpoint「${pending.endpointId}」写入 .env 与 ${filePath}。\n` +
|
|
103
|
-
`intents: ${intentFields.intents.join(', ')}。\n` +
|
|
104
|
-
'⚠️ 需重启 zhin 后新 endpoint 才会生效。'
|
|
105
|
-
);
|
|
105
|
+
return t(locale, 'endpointSaved', { kind: intentFields.botKind, id: pending.endpointId, file: filePath, intents: intentFields.intents.join(', ') });
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
@@ -129,10 +129,13 @@ export function runQqEndpointAdd(
|
|
|
129
129
|
reply: QqCommandReply,
|
|
130
130
|
store: EndpointConfigurationStore,
|
|
131
131
|
input?: unknown,
|
|
132
|
+
locale: Locale = 'zh-CN',
|
|
133
|
+
cancelCommand?: string,
|
|
134
|
+
commandHeads?: readonly string[],
|
|
132
135
|
): Promise<string> {
|
|
133
136
|
if (state.bindFlow || state.pendingBotKind) {
|
|
134
137
|
return Promise.resolve(
|
|
135
|
-
|
|
138
|
+
t(locale, 'existingFlow'),
|
|
136
139
|
);
|
|
137
140
|
}
|
|
138
141
|
const endpointId = id?.trim() || undefined;
|
|
@@ -155,12 +158,12 @@ export function runQqEndpointAdd(
|
|
|
155
158
|
onQrDisplayed: (url) => {
|
|
156
159
|
// QQ 出站当前仅支持纯文本(富媒体未迁移),二维码只能发链接文本
|
|
157
160
|
settle(
|
|
158
|
-
|
|
161
|
+
t(locale, 'scanLink', { url }),
|
|
159
162
|
);
|
|
160
163
|
},
|
|
161
164
|
onQrExpired: async () => {
|
|
162
165
|
try {
|
|
163
|
-
await reply('
|
|
166
|
+
await reply(t(locale, 'qrExpired'));
|
|
164
167
|
} catch {
|
|
165
168
|
// same as settle follow-up: best-effort after command scope ends
|
|
166
169
|
}
|
|
@@ -179,12 +182,12 @@ export function runQqEndpointAdd(
|
|
|
179
182
|
endpointId: finalName,
|
|
180
183
|
appId,
|
|
181
184
|
appSecret,
|
|
182
|
-
sessionKey: '',
|
|
185
|
+
sessionKey: '', locale, cancelCommand,
|
|
183
186
|
},
|
|
184
187
|
'public',
|
|
185
188
|
store,
|
|
186
189
|
) +
|
|
187
|
-
'
|
|
190
|
+
t(locale, 'noInteractiveKind'),
|
|
188
191
|
);
|
|
189
192
|
return;
|
|
190
193
|
}
|
|
@@ -193,40 +196,40 @@ export function runQqEndpointAdd(
|
|
|
193
196
|
endpointId: finalName,
|
|
194
197
|
appId,
|
|
195
198
|
appSecret,
|
|
196
|
-
sessionKey,
|
|
199
|
+
sessionKey, locale, cancelCommand, commandHeads,
|
|
197
200
|
};
|
|
198
201
|
settle(
|
|
199
|
-
|
|
200
|
-
QQ_BOT_KIND_PROMPT,
|
|
202
|
+
t(locale, 'scanned', { id: finalName, prompt: qqBotKindPrompt(locale, cancelCommand) }),
|
|
201
203
|
);
|
|
202
204
|
} catch (error) {
|
|
203
|
-
settle(
|
|
205
|
+
settle(t(locale, 'writeFailed', { error: error instanceof Error ? error.message : String(error) }));
|
|
204
206
|
}
|
|
205
207
|
},
|
|
206
208
|
onFailure: (error) => {
|
|
207
209
|
state.bindFlow = null;
|
|
208
|
-
settle(
|
|
210
|
+
settle(t(locale, 'bindingFailed', { error: error.message }));
|
|
209
211
|
},
|
|
210
212
|
},
|
|
211
|
-
{ source: 'zhin' },
|
|
213
|
+
{ source: 'zhin', locale },
|
|
212
214
|
);
|
|
213
|
-
state.bindFlow = { id: endpointId, stop };
|
|
215
|
+
state.bindFlow = { id: endpointId, stop, locale, cancelCommand, commandHeads };
|
|
214
216
|
});
|
|
215
217
|
}
|
|
216
218
|
|
|
217
219
|
/** `qq endpoint cancel`:中止进行中的绑定或待选 botKind */
|
|
218
|
-
export function runQqEndpointCancel(state: QqRuntimeState): string {
|
|
220
|
+
export function runQqEndpointCancel(state: QqRuntimeState, preference?: Locale): string {
|
|
221
|
+
const locale = resolveLocale(state.pendingBotKind?.locale ?? state.bindFlow?.locale ?? preference);
|
|
219
222
|
if (state.bindFlow) {
|
|
220
223
|
state.bindFlow.stop();
|
|
221
224
|
state.bindFlow = null;
|
|
222
|
-
return
|
|
225
|
+
return t(locale, 'cancelled');
|
|
223
226
|
}
|
|
224
227
|
if (state.pendingBotKind) {
|
|
225
228
|
const name = state.pendingBotKind.endpointId;
|
|
226
229
|
state.pendingBotKind = null;
|
|
227
|
-
return
|
|
230
|
+
return t(locale, 'pendingCancelled', { name });
|
|
228
231
|
}
|
|
229
|
-
return
|
|
232
|
+
return t(locale, 'noFlow');
|
|
230
233
|
}
|
|
231
234
|
|
|
232
235
|
/** `qq endpoint remove <id>`:从 zhin.config.yml 移除对应 endpoints 项 */
|
|
@@ -245,19 +248,21 @@ export async function runQqEndpointRemove(
|
|
|
245
248
|
export const qqEndpointCommands = createEndpointCommands({
|
|
246
249
|
...qqEndpointListSpec,
|
|
247
250
|
fields: [
|
|
248
|
-
{ key: 'appid', required: true, env: true, description: 'QQ 机器人 appid' },
|
|
249
|
-
{ key: 'secret', required: true, env: true, description: 'QQ 机器人 secret' },
|
|
251
|
+
{ key: 'appid', required: true, env: true, description: 'QQ 机器人 appid', descriptionI18n: { en: "QQ bot app ID" } },
|
|
252
|
+
{ key: 'secret', required: true, env: true, description: 'QQ 机器人 secret', descriptionI18n: { en: "QQ bot secret" } },
|
|
250
253
|
],
|
|
251
254
|
running: (use) => use(qqRuntimeStateToken).endpoints.values(),
|
|
252
|
-
listFooter: (use) => busyFooter(use(qqRuntimeStateToken)),
|
|
255
|
+
listFooter: (use, locale) => busyFooter(use(qqRuntimeStateToken), locale),
|
|
256
|
+
addDescriptionI18n: { en: 'Bind a bot through mobile QQ, choose public/private, then save configuration; restart to apply' },
|
|
253
257
|
addDescription:
|
|
254
258
|
'手机 QQ 扫码绑定机器人:确认公域/私域后一次性写入 .env 与 zhin.config.yml(重启生效)',
|
|
255
|
-
bindFlow: ({ id, reply, input, use }) =>
|
|
259
|
+
bindFlow: ({ id, reply, input, use, locale, formatCommand }) =>
|
|
256
260
|
runQqEndpointAdd(
|
|
257
261
|
use(qqRuntimeStateToken),
|
|
258
262
|
id,
|
|
259
263
|
reply,
|
|
260
264
|
use(endpointConfigurationStoreToken),
|
|
261
|
-
input,
|
|
265
|
+
input, locale, formatCommand?.('qq endpoint cancel'),
|
|
266
|
+
['qq endpoint list', 'qq endpoint add', 'qq endpoint remove', 'qq endpoint cancel'].flatMap(path => { const head = formatCommand?.(path); return head ? [head] : []; }),
|
|
262
267
|
),
|
|
263
268
|
}, defineCommand);
|
package/lib/endpoint.js
CHANGED
|
@@ -10,6 +10,8 @@ import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
|
10
10
|
import { Endpoint, createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
|
|
11
11
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
12
12
|
import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
|
|
13
|
+
import { QqActiveMessageGate, QqPassiveReplyLedger } from './passive-reply.js';
|
|
14
|
+
import { sendQqMessages } from './qq-send.js';
|
|
13
15
|
import { formatInboundContent, parseCompoundMessageId, qqInboundConversation, qqOutboundKind, resolveOutboundMessageId, senderDisplayName, } from './protocol.js';
|
|
14
16
|
import { defaultCreateHttpBot, registerQqWebhookRoutes, } from './webhook.js';
|
|
15
17
|
import { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
|
|
@@ -21,6 +23,9 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
21
23
|
#bot = null;
|
|
22
24
|
#open = false;
|
|
23
25
|
#seenInteractions = new Map();
|
|
26
|
+
/** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
|
|
27
|
+
#passiveReplies = new QqPassiveReplyLedger();
|
|
28
|
+
#activeGate = new QqActiveMessageGate();
|
|
24
29
|
#lifecycle;
|
|
25
30
|
#cleanup;
|
|
26
31
|
management = createQqEndpointManagement(() => this.#requireBot());
|
|
@@ -124,24 +129,12 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
124
129
|
const bot = this.#requireBot();
|
|
125
130
|
let result;
|
|
126
131
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
result = await bot.sendGroupMessage(conversation.id, message);
|
|
134
|
-
break;
|
|
135
|
-
case 'channel':
|
|
136
|
-
result = await bot.sendGuildMessage(conversation.id, message);
|
|
137
|
-
break;
|
|
138
|
-
case 'direct':
|
|
139
|
-
if (!bot.sendDirectMessage)
|
|
140
|
-
throw new Error('QQ direct message not supported by transport');
|
|
141
|
-
result = await bot.sendDirectMessage(conversation.id, message);
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
132
|
+
result = await sendQqMessages({
|
|
133
|
+
bot,
|
|
134
|
+
ledger: this.#passiveReplies,
|
|
135
|
+
activeGate: this.#activeGate,
|
|
136
|
+
log: fields => this.#logger.warn(formatCompact(fields)),
|
|
137
|
+
}, conversation, messages);
|
|
145
138
|
}
|
|
146
139
|
catch (error) {
|
|
147
140
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
@@ -176,6 +169,8 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
176
169
|
+ (msg.mentioned ? ' (mentioned)' : '')
|
|
177
170
|
+ ` | ${truncatePreview(content, 80)}`);
|
|
178
171
|
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
172
|
+
if (msg.id)
|
|
173
|
+
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
|
|
179
174
|
void this.emit('message.receive', {
|
|
180
175
|
conversation,
|
|
181
176
|
message: { conversation, id: msg.id },
|
|
@@ -224,6 +219,8 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
224
219
|
void action.ack().then(ok => { if (ok === false)
|
|
225
220
|
this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
|
|
226
221
|
const conversation = qqInboundConversation(String(this.#options.id), action.message);
|
|
222
|
+
// INTERACTION_CREATE's id is an event_id the reply may answer passively.
|
|
223
|
+
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
|
|
227
224
|
void this.emit('message.receive', {
|
|
228
225
|
conversation, message: { conversation, id: action.message.id }, content: '',
|
|
229
226
|
segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
|
|
@@ -274,6 +271,9 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
274
271
|
#bot = null;
|
|
275
272
|
#open = false;
|
|
276
273
|
#seenInteractions = new Map();
|
|
274
|
+
/** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
|
|
275
|
+
#passiveReplies = new QqPassiveReplyLedger();
|
|
276
|
+
#activeGate = new QqActiveMessageGate();
|
|
277
277
|
#lifecycle;
|
|
278
278
|
#cleanup;
|
|
279
279
|
management = createQqEndpointManagement(() => this.#requireBot());
|
|
@@ -379,24 +379,12 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
379
379
|
const bot = this.#requireBot();
|
|
380
380
|
let result;
|
|
381
381
|
try {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
result = await bot.sendGroupMessage(conversation.id, message);
|
|
389
|
-
break;
|
|
390
|
-
case 'channel':
|
|
391
|
-
result = await bot.sendGuildMessage(conversation.id, message);
|
|
392
|
-
break;
|
|
393
|
-
case 'direct':
|
|
394
|
-
if (!bot.sendDirectMessage)
|
|
395
|
-
throw new Error('QQ direct message not supported by transport');
|
|
396
|
-
result = await bot.sendDirectMessage(conversation.id, message);
|
|
397
|
-
break;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
382
|
+
result = await sendQqMessages({
|
|
383
|
+
bot,
|
|
384
|
+
ledger: this.#passiveReplies,
|
|
385
|
+
activeGate: this.#activeGate,
|
|
386
|
+
log: fields => this.#logger.warn(formatCompact(fields)),
|
|
387
|
+
}, conversation, messages);
|
|
400
388
|
}
|
|
401
389
|
catch (error) {
|
|
402
390
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
@@ -430,6 +418,8 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
430
418
|
+ (msg.mentioned ? ' (mentioned)' : '')
|
|
431
419
|
+ ` | ${truncatePreview(content, 80)}`);
|
|
432
420
|
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
421
|
+
if (msg.id)
|
|
422
|
+
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
|
|
433
423
|
void this.emit('message.receive', {
|
|
434
424
|
conversation,
|
|
435
425
|
message: { conversation, id: msg.id },
|
|
@@ -478,6 +468,8 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
478
468
|
void action.ack().then(ok => { if (ok === false)
|
|
479
469
|
this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
|
|
480
470
|
const conversation = qqInboundConversation(String(this.#options.id), action.message);
|
|
471
|
+
// INTERACTION_CREATE's id is an event_id the reply may answer passively.
|
|
472
|
+
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
|
|
481
473
|
void this.emit('message.receive', {
|
|
482
474
|
conversation, message: { conversation, id: action.message.id }, content: '',
|
|
483
475
|
segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
|
package/lib/i18n.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const messages: (locale: import("zhin.js").Locale | undefined, key: "bindTaskFailed" | "kindPrompt" | "cancelHint" | "bindingBusy" | "kindBusy" | "endpointSaved" | "existingFlow" | "scanLink" | "qrExpired" | "noInteractiveKind" | "scanned" | "writeFailed" | "bindingFailed" | "cancelled" | "pendingCancelled" | "noFlow" | "kindUnrecognized" | "configFailed", values?: import("zhin.js").MessageValues) => string;
|
package/lib/i18n.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createMessages } from 'zhin.js/i18n';
|
|
2
|
+
export const messages = createMessages({
|
|
3
|
+
bindTaskFailed: { 'zh-CN': '获取绑定任务失败: {error}', en: 'Could not create the binding task: {error}' },
|
|
4
|
+
"kindPrompt": {
|
|
5
|
+
"zh-CN": "请回复 public(公域)或 private(私域),确认后一次性写入 .env 与配置。",
|
|
6
|
+
"en": "Reply public or private to save .env and configuration together."
|
|
7
|
+
},
|
|
8
|
+
"cancelHint": {
|
|
9
|
+
"zh-CN": "也可发 {command} 取消。",
|
|
10
|
+
"en": "Send {command} to cancel."
|
|
11
|
+
},
|
|
12
|
+
"bindingBusy": {
|
|
13
|
+
"zh-CN": "⚠️ 有进行中的扫码绑定,请先取消当前绑定流程",
|
|
14
|
+
"en": "⚠️ A QR binding is in progress; cancel it before starting another"
|
|
15
|
+
},
|
|
16
|
+
"kindBusy": {
|
|
17
|
+
"zh-CN": "⚠️ endpoint「{id}」等待公域/私域选择,请回复 public/private",
|
|
18
|
+
"en": "⚠️ Endpoint “{id}” is waiting for public/private selection"
|
|
19
|
+
},
|
|
20
|
+
"endpointSaved": {
|
|
21
|
+
"zh-CN": "✅ 已按 botKind={kind} 将 endpoint「{id}」写入 .env 与 {file}。\nintents: {intents}。\n⚠️ 需重启 zhin 后新 endpoint 才会生效。",
|
|
22
|
+
"en": "✅ Saved endpoint “{id}” with botKind={kind} to .env and {file}.\nintents: {intents}.\n⚠️ Restart zhin to activate the new endpoint."
|
|
23
|
+
},
|
|
24
|
+
"existingFlow": {
|
|
25
|
+
"zh-CN": "已有进行中的 QQ 机器人绑定或待确认的公域/私域选择,请先取消后再试",
|
|
26
|
+
"en": "A QQ binding or public/private selection is already in progress; cancel it before trying again"
|
|
27
|
+
},
|
|
28
|
+
"scanLink": {
|
|
29
|
+
"zh-CN": "请用手机 QQ 打开以下链接完成扫码绑定(二维码图片出站暂未支持,故发送链接):\n{url}",
|
|
30
|
+
"en": "Open this link in mobile QQ to scan and bind the bot:\n{url}"
|
|
31
|
+
},
|
|
32
|
+
"qrExpired": {
|
|
33
|
+
"zh-CN": "二维码已过期,正在刷新,请扫描新链接…",
|
|
34
|
+
"en": "The QR code expired. Refreshing; scan the new link…"
|
|
35
|
+
},
|
|
36
|
+
"noInteractiveKind": {
|
|
37
|
+
"zh-CN": "\n(当前会话无法交互选择公/私域;私域请手动改配置。)",
|
|
38
|
+
"en": "\n(This source cannot answer public/private selection. Edit the configuration manually for a private bot.)"
|
|
39
|
+
},
|
|
40
|
+
"scanned": {
|
|
41
|
+
"zh-CN": "✅ 扫码成功!endpoint「{id}」。\n{prompt}",
|
|
42
|
+
"en": "✅ QR binding succeeded for endpoint “{id}”.\n{prompt}"
|
|
43
|
+
},
|
|
44
|
+
"writeFailed": {
|
|
45
|
+
"zh-CN": "绑定成功但写入配置失败:{error}",
|
|
46
|
+
"en": "Binding succeeded but configuration could not be saved: {error}"
|
|
47
|
+
},
|
|
48
|
+
"bindingFailed": {
|
|
49
|
+
"zh-CN": "❌ QQ 绑定失败:{error}",
|
|
50
|
+
"en": "❌ QQ binding failed: {error}"
|
|
51
|
+
},
|
|
52
|
+
"cancelled": {
|
|
53
|
+
"zh-CN": "已取消进行中的 QQ 绑定流程",
|
|
54
|
+
"en": "The active QQ binding was cancelled"
|
|
55
|
+
},
|
|
56
|
+
"pendingCancelled": {
|
|
57
|
+
"zh-CN": "已取消 endpoint「{name}」的绑定(尚未写入任何文件)",
|
|
58
|
+
"en": "Binding for endpoint “{name}” cancelled; no files were written"
|
|
59
|
+
},
|
|
60
|
+
"noFlow": {
|
|
61
|
+
"zh-CN": "当前没有进行中的 QQ 绑定流程",
|
|
62
|
+
"en": "No QQ binding is in progress"
|
|
63
|
+
},
|
|
64
|
+
"kindUnrecognized": {
|
|
65
|
+
"zh-CN": "未识别「{raw}」。{prompt}",
|
|
66
|
+
"en": "Unrecognized answer “{raw}”. {prompt}"
|
|
67
|
+
},
|
|
68
|
+
"configFailed": {
|
|
69
|
+
"zh-CN": "写入配置失败:{error}",
|
|
70
|
+
"en": "Could not save configuration: {error}"
|
|
71
|
+
}
|
|
72
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passive replies on the QQ open platform.
|
|
3
|
+
*
|
|
4
|
+
* A message sent with the `msg_id` of a recent user message (or the `event_id` of a recent event)
|
|
5
|
+
* is a passive reply; without one it is an active message, which has quotas and can be switched
|
|
6
|
+
* off by the user. Each source may be replied to a limited number of times within a window, and
|
|
7
|
+
* `msg_seq` must differ for every reply to the same source. The ledger remembers recent sources
|
|
8
|
+
* per conversation and hands out the newest one still usable, with its next `msg_seq`.
|
|
9
|
+
*
|
|
10
|
+
* Limits from https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/overview.html
|
|
11
|
+
*/
|
|
12
|
+
import type { QqChannelKind as QqOutboundKind } from './protocol.js';
|
|
13
|
+
/** Reply-to source for the SDK: a user message id or an event id. */
|
|
14
|
+
export type QqReplySource = {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
} | {
|
|
17
|
+
readonly event_id: string;
|
|
18
|
+
};
|
|
19
|
+
export interface QqPassiveLimit {
|
|
20
|
+
readonly windowMs: number;
|
|
21
|
+
readonly maxReplies: number;
|
|
22
|
+
}
|
|
23
|
+
/** C2C: 60 min, 4 replies. Group: 5 min, 5 replies. Guild channel and DM: 5 min (count unpublished; 5 assumed). */
|
|
24
|
+
export declare const QQ_PASSIVE_LIMITS: Readonly<Record<QqOutboundKind, QqPassiveLimit>>;
|
|
25
|
+
export interface PassiveReplyGrant {
|
|
26
|
+
readonly source: QqReplySource;
|
|
27
|
+
readonly msgSeq: number;
|
|
28
|
+
}
|
|
29
|
+
export declare class QqPassiveReplyLedger {
|
|
30
|
+
#private;
|
|
31
|
+
private readonly now;
|
|
32
|
+
constructor(now?: () => number);
|
|
33
|
+
/** A user message or event in `conversation` that later replies may answer. */
|
|
34
|
+
record(kind: QqOutboundKind, conversationId: string, source: QqReplySource, receivedAt?: number): void;
|
|
35
|
+
/**
|
|
36
|
+
* Claim one reply: `preferred` (an explicit reply segment) when it is still usable, otherwise the
|
|
37
|
+
* newest usable source in the conversation. Undefined when only an active message is possible.
|
|
38
|
+
*/
|
|
39
|
+
acquire(kind: QqOutboundKind, conversationId: string, preferred?: QqReplySource): PassiveReplyGrant | undefined;
|
|
40
|
+
/** The platform said this source can no longer be replied to (expired, over the limit, invalid id). */
|
|
41
|
+
exhaust(kind: QqOutboundKind, conversationId: string, source: QqReplySource): void;
|
|
42
|
+
/** Drop conversations whose every source is past its window. */
|
|
43
|
+
prune(): void;
|
|
44
|
+
}
|
|
45
|
+
/** Platform codes meaning the reply source is unusable: switch to an active message. */
|
|
46
|
+
export declare const QQ_PASSIVE_SOURCE_CODES: ReadonlySet<number>;
|
|
47
|
+
/** Platform codes meaning active messages are unavailable for this conversation for now. */
|
|
48
|
+
export declare const QQ_ACTIVE_UNAVAILABLE_CODES: ReadonlySet<number>;
|
|
49
|
+
/** Platform codes meaning Markdown is not allowed: resend as plain text. */
|
|
50
|
+
export declare const QQ_MARKDOWN_DENIED_CODES: ReadonlySet<number>;
|
|
51
|
+
/** How long a conversation is not offered active messages after the platform refused them. */
|
|
52
|
+
export declare const ACTIVE_UNAVAILABLE_BACKOFF_MS: number;
|
|
53
|
+
/** Conversations where the platform recently refused active messages. */
|
|
54
|
+
export declare class QqActiveMessageGate {
|
|
55
|
+
#private;
|
|
56
|
+
private readonly now;
|
|
57
|
+
constructor(now?: () => number);
|
|
58
|
+
blocked(kind: QqOutboundKind, conversationId: string): boolean;
|
|
59
|
+
/** Returns true the first time a conversation is blocked, so the caller can log it once. */
|
|
60
|
+
block(kind: QqOutboundKind, conversationId: string): boolean;
|
|
61
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** C2C: 60 min, 4 replies. Group: 5 min, 5 replies. Guild channel and DM: 5 min (count unpublished; 5 assumed). */
|
|
2
|
+
export const QQ_PASSIVE_LIMITS = Object.freeze({
|
|
3
|
+
private: { windowMs: 60 * 60_000, maxReplies: 4 },
|
|
4
|
+
group: { windowMs: 5 * 60_000, maxReplies: 5 },
|
|
5
|
+
channel: { windowMs: 5 * 60_000, maxReplies: 5 },
|
|
6
|
+
direct: { windowMs: 5 * 60_000, maxReplies: 5 },
|
|
7
|
+
});
|
|
8
|
+
/** Stop using a source this long before the platform window closes: the request itself takes time. */
|
|
9
|
+
const SAFETY_MARGIN_MS = 15_000;
|
|
10
|
+
/** Sources remembered per conversation; older ones are long expired in practice. */
|
|
11
|
+
const MAX_SOURCES_PER_CONVERSATION = 8;
|
|
12
|
+
function sourceKey(source) {
|
|
13
|
+
return 'id' in source ? `msg:${source.id}` : `event:${source.event_id}`;
|
|
14
|
+
}
|
|
15
|
+
/** Expired conversations are dropped every this many records. */
|
|
16
|
+
const PRUNE_EVERY_RECORDS = 256;
|
|
17
|
+
export class QqPassiveReplyLedger {
|
|
18
|
+
now;
|
|
19
|
+
#conversations = new Map();
|
|
20
|
+
#records = 0;
|
|
21
|
+
constructor(now = Date.now) {
|
|
22
|
+
this.now = now;
|
|
23
|
+
}
|
|
24
|
+
/** A user message or event in `conversation` that later replies may answer. */
|
|
25
|
+
record(kind, conversationId, source, receivedAt = this.now()) {
|
|
26
|
+
const key = `${kind}:${conversationId}`;
|
|
27
|
+
const entries = (this.#conversations.get(key) ?? []).filter(entry => sourceKey(entry.source) !== sourceKey(source));
|
|
28
|
+
entries.push({ source, receivedAt, replies: 0, exhausted: false });
|
|
29
|
+
entries.sort((a, b) => a.receivedAt - b.receivedAt);
|
|
30
|
+
this.#conversations.set(key, entries.slice(-MAX_SOURCES_PER_CONVERSATION));
|
|
31
|
+
if (++this.#records % PRUNE_EVERY_RECORDS === 0)
|
|
32
|
+
this.prune();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Claim one reply: `preferred` (an explicit reply segment) when it is still usable, otherwise the
|
|
36
|
+
* newest usable source in the conversation. Undefined when only an active message is possible.
|
|
37
|
+
*/
|
|
38
|
+
acquire(kind, conversationId, preferred) {
|
|
39
|
+
const limit = QQ_PASSIVE_LIMITS[kind];
|
|
40
|
+
const now = this.now();
|
|
41
|
+
const entries = this.#conversations.get(`${kind}:${conversationId}`) ?? [];
|
|
42
|
+
const usable = (entry) => !entry.exhausted
|
|
43
|
+
&& entry.replies < limit.maxReplies
|
|
44
|
+
&& now - entry.receivedAt < limit.windowMs - SAFETY_MARGIN_MS;
|
|
45
|
+
const preferredKey = preferred ? sourceKey(preferred) : undefined;
|
|
46
|
+
const entry = (preferredKey ? entries.find(item => sourceKey(item.source) === preferredKey && usable(item)) : undefined)
|
|
47
|
+
?? [...entries].reverse().find(usable);
|
|
48
|
+
if (!entry)
|
|
49
|
+
return undefined;
|
|
50
|
+
entry.replies += 1;
|
|
51
|
+
return { source: entry.source, msgSeq: entry.replies };
|
|
52
|
+
}
|
|
53
|
+
/** The platform said this source can no longer be replied to (expired, over the limit, invalid id). */
|
|
54
|
+
exhaust(kind, conversationId, source) {
|
|
55
|
+
const key = sourceKey(source);
|
|
56
|
+
const entry = this.#conversations.get(`${kind}:${conversationId}`)?.find(item => sourceKey(item.source) === key);
|
|
57
|
+
if (entry)
|
|
58
|
+
entry.exhausted = true;
|
|
59
|
+
}
|
|
60
|
+
/** Drop conversations whose every source is past its window. */
|
|
61
|
+
prune() {
|
|
62
|
+
const now = this.now();
|
|
63
|
+
for (const [key, entries] of this.#conversations) {
|
|
64
|
+
const kind = key.slice(0, key.indexOf(':'));
|
|
65
|
+
const windowMs = QQ_PASSIVE_LIMITS[kind]?.windowMs ?? 0;
|
|
66
|
+
const live = entries.filter(entry => now - entry.receivedAt < windowMs);
|
|
67
|
+
if (live.length === 0)
|
|
68
|
+
this.#conversations.delete(key);
|
|
69
|
+
else
|
|
70
|
+
this.#conversations.set(key, live);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Platform codes meaning the reply source is unusable: switch to an active message. */
|
|
75
|
+
export const QQ_PASSIVE_SOURCE_CODES = new Set([
|
|
76
|
+
40034128, // 被动回复时间或次数超限
|
|
77
|
+
40034005, // 回复消息 msg_id 已过期
|
|
78
|
+
304027, // MSG_EXPIRE 回复的消息过期
|
|
79
|
+
304026, // MSG_ID 回复的消息 id 错误
|
|
80
|
+
306002, // msgid error
|
|
81
|
+
501005, 501008, 501009, // MessageID 错误 / 不存在 / 解析失败
|
|
82
|
+
]);
|
|
83
|
+
/** Platform codes meaning active messages are unavailable for this conversation for now. */
|
|
84
|
+
export const QQ_ACTIVE_UNAVAILABLE_CODES = new Set([
|
|
85
|
+
40034105, // 主动消息发送失败,无权限
|
|
86
|
+
304031, 304032, 304034, // 私信已关闭 / 不存在 / 不是私信成员
|
|
87
|
+
304035, 304045, 304047, 304049, 304050, // 主动消息额度、频控
|
|
88
|
+
304046, 304048, // 不允许在此(子)频道发主动消息
|
|
89
|
+
]);
|
|
90
|
+
/** Platform codes meaning Markdown is not allowed: resend as plain text. */
|
|
91
|
+
export const QQ_MARKDOWN_DENIED_CODES = new Set([
|
|
92
|
+
304036, // 没有 markdown 模板的权限
|
|
93
|
+
304014, // TEMPLATE_PRIVILEGE 没有模板权限
|
|
94
|
+
50056, // 不允许发送 markdown content
|
|
95
|
+
]);
|
|
96
|
+
/** How long a conversation is not offered active messages after the platform refused them. */
|
|
97
|
+
export const ACTIVE_UNAVAILABLE_BACKOFF_MS = 60 * 60_000;
|
|
98
|
+
/** Conversations where the platform recently refused active messages. */
|
|
99
|
+
export class QqActiveMessageGate {
|
|
100
|
+
now;
|
|
101
|
+
#blockedUntil = new Map();
|
|
102
|
+
constructor(now = Date.now) {
|
|
103
|
+
this.now = now;
|
|
104
|
+
}
|
|
105
|
+
blocked(kind, conversationId) {
|
|
106
|
+
const key = `${kind}:${conversationId}`;
|
|
107
|
+
const until = this.#blockedUntil.get(key);
|
|
108
|
+
if (until === undefined)
|
|
109
|
+
return false;
|
|
110
|
+
if (this.now() < until)
|
|
111
|
+
return true;
|
|
112
|
+
this.#blockedUntil.delete(key);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
/** Returns true the first time a conversation is blocked, so the caller can log it once. */
|
|
116
|
+
block(kind, conversationId) {
|
|
117
|
+
const key = `${kind}:${conversationId}`;
|
|
118
|
+
const fresh = !this.blocked(kind, conversationId);
|
|
119
|
+
this.#blockedUntil.set(key, this.now() + ACTIVE_UNAVAILABLE_BACKOFF_MS);
|
|
120
|
+
return fresh;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/lib/qq-bind-flow.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Locale } from 'zhin.js/i18n';
|
|
1
2
|
export interface QqBindCredentials {
|
|
2
3
|
appId: string;
|
|
3
4
|
appSecret: string;
|
|
@@ -10,6 +11,7 @@ export interface QqBindCallbacks {
|
|
|
10
11
|
onQrExpired?: () => void | Promise<void>;
|
|
11
12
|
}
|
|
12
13
|
export interface QqBindFlowOptions {
|
|
14
|
+
locale?: Locale;
|
|
13
15
|
signal?: AbortSignal;
|
|
14
16
|
source?: string;
|
|
15
17
|
}
|
package/lib/qq-bind-flow.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { messages as t } from './i18n.js';
|
|
1
2
|
/**
|
|
2
3
|
* QQ Bot 扫码绑定流程(内联,等价 qqbot-connector startQrConnect)
|
|
3
4
|
*/
|
|
@@ -60,7 +61,7 @@ export function startQqBindFlow(callbacks, options = {}) {
|
|
|
60
61
|
task = await createBindTask();
|
|
61
62
|
}
|
|
62
63
|
catch (err) {
|
|
63
|
-
throw new Error(
|
|
64
|
+
throw new Error(t(options.locale, 'bindTaskFailed', { error: err instanceof Error ? err.message : String(err) }), { cause: err });
|
|
64
65
|
}
|
|
65
66
|
const connectUrl = buildConnectUrl(task.taskId, options.source ?? 'zhin');
|
|
66
67
|
await callbacks.onQrDisplayed?.(connectUrl);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import { type Locale } from 'zhin.js/i18n';
|
|
1
2
|
import { type QqBotKind } from './qq-intents.js';
|
|
2
|
-
/**
|
|
3
|
+
/** Bind replies to the complete canonical conversation and actual sender. */
|
|
3
4
|
export declare function qqCommandSessionKey(input: unknown): string | undefined;
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*/
|
|
8
|
-
export declare function parseQqBotKindAnswer(raw: unknown): QqBotKind | undefined;
|
|
5
|
+
/** Public/private are stable botKind values; Chinese aliases apply only to Chinese flows. */
|
|
6
|
+
export declare function parseQqBotKindAnswer(raw: unknown, locale?: Locale): QqBotKind | undefined;
|
|
7
|
+
export declare function qqBotKindPrompt(locale?: Locale, cancelCommand?: string): string;
|
|
9
8
|
export declare const QQ_BOT_KIND_PROMPT: string;
|