@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +31 -1
  3. package/agents/qq/messages.js +24 -0
  4. package/agents/qq/messages.ts +24 -0
  5. package/agents/qq/skills/qq/SKILL.md +16 -10
  6. package/agents/qq/skills/qq/tools/add_role/index.js +3 -1
  7. package/agents/qq/skills/qq/tools/add_role/index.ts +3 -1
  8. package/agents/qq/skills/qq/tools/channel_info/index.js +1 -0
  9. package/agents/qq/skills/qq/tools/channel_info/index.ts +1 -0
  10. package/agents/qq/skills/qq/tools/create_role/index.js +3 -1
  11. package/agents/qq/skills/qq/tools/create_role/index.ts +3 -1
  12. package/agents/qq/skills/qq/tools/list_channels/index.js +1 -0
  13. package/agents/qq/skills/qq/tools/list_channels/index.ts +1 -0
  14. package/agents/qq/skills/qq/tools/list_roles/index.js +1 -0
  15. package/agents/qq/skills/qq/tools/list_roles/index.ts +1 -0
  16. package/agents/qq/skills/qq/tools/member_detail/index.js +1 -0
  17. package/agents/qq/skills/qq/tools/member_detail/index.ts +1 -0
  18. package/agents/qq/skills/qq/tools/remove_role/index.js +3 -1
  19. package/agents/qq/skills/qq/tools/remove_role/index.ts +3 -1
  20. package/agents/qq/skills/qq-directory/SKILL.md +14 -0
  21. package/commands/qq/endpoint/cancel/index.js +6 -5
  22. package/commands/qq/endpoint/cancel/index.ts +5 -5
  23. package/commands/qq/endpoint/definition.js +32 -30
  24. package/commands/qq/endpoint/definition.ts +36 -31
  25. package/lib/endpoint.js +28 -36
  26. package/lib/i18n.d.ts +1 -0
  27. package/lib/i18n.js +72 -0
  28. package/lib/passive-reply.d.ts +61 -0
  29. package/lib/passive-reply.js +122 -0
  30. package/lib/qq-bind-flow.d.ts +2 -0
  31. package/lib/qq-bind-flow.js +2 -1
  32. package/lib/qq-bot-kind-prompt.d.ts +5 -6
  33. package/lib/qq-bot-kind-prompt.js +26 -26
  34. package/lib/qq-runtime-state.d.ts +6 -0
  35. package/lib/qq-send.d.ts +28 -0
  36. package/lib/qq-send.js +109 -0
  37. package/lib/webhook.js +4 -4
  38. package/lib/ws.d.ts +10 -4
  39. package/lib/ws.js +4 -4
  40. package/middlewares/bot-kind-reply/index.js +6 -5
  41. package/middlewares/bot-kind-reply/index.ts +6 -5
  42. package/package.json +18 -18
  43. package/schema.json +59 -9
  44. package/src/endpoint.ts +26 -34
  45. package/src/i18n.ts +73 -0
  46. package/src/passive-reply.ts +159 -0
  47. package/src/qq-bind-flow.ts +4 -1
  48. package/src/qq-bot-kind-prompt.ts +22 -37
  49. package/src/qq-runtime-state.ts +6 -0
  50. package/src/qq-send.ts +138 -0
  51. package/src/webhook.ts +4 -4
  52. package/src/ws.ts +15 -8
  53. /package/agents/qq/skills/{qq → qq-directory}/tools/list_guilds/index.js +0 -0
  54. /package/agents/qq/skills/{qq → qq-directory}/tools/list_guilds/index.ts +0 -0
package/src/endpoint.ts CHANGED
@@ -20,6 +20,8 @@ import type { HttpHost } from '@zhin.js/host-http';
20
20
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
21
21
  import type { CapabilityId } from 'zhin.js';
22
22
  import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
23
+ import { QqActiveMessageGate, QqPassiveReplyLedger } from './passive-reply.js';
24
+ import { sendQqMessages } from './qq-send.js';
23
25
  import {
24
26
  formatInboundContent,
25
27
  parseCompoundMessageId,
@@ -67,6 +69,9 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
67
69
  #bot: QqBotTransport | null = null;
68
70
  #open = false;
69
71
  readonly #seenInteractions = new Map<string, number>();
72
+ /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
73
+ readonly #passiveReplies = new QqPassiveReplyLedger();
74
+ readonly #activeGate = new QqActiveMessageGate();
70
75
  readonly #lifecycle: EndpointLifecycle;
71
76
  #cleanup?: () => Promise<void>;
72
77
  readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
@@ -169,23 +174,12 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
169
174
  const bot = this.#requireBot();
170
175
  let result: unknown;
171
176
  try {
172
- for (const message of messages) {
173
- switch (kind) {
174
- case 'private':
175
- result = await bot.sendPrivateMessage(conversation.id, message);
176
- break;
177
- case 'group':
178
- result = await bot.sendGroupMessage(conversation.id, message);
179
- break;
180
- case 'channel':
181
- result = await bot.sendGuildMessage(conversation.id, message);
182
- break;
183
- case 'direct':
184
- if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
185
- result = await bot.sendDirectMessage(conversation.id, message);
186
- break;
187
- }
188
- }
177
+ result = await sendQqMessages({
178
+ bot,
179
+ ledger: this.#passiveReplies,
180
+ activeGate: this.#activeGate,
181
+ log: fields => this.#logger.warn(formatCompact(fields)),
182
+ }, conversation, messages);
189
183
  } catch (error) {
190
184
  const { failure, diagnostic } = qqDeliveryFailure(error);
191
185
  this.#logger.warn(formatCompact(diagnostic));
@@ -224,6 +218,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
224
218
  + ` | ${truncatePreview(content, 80)}`,
225
219
  );
226
220
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
221
+ if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
227
222
  void this.emit('message.receive', {
228
223
  conversation,
229
224
  message: { conversation, id: msg.id },
@@ -266,6 +261,8 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
266
261
  // ACK owns only platform loading state, never implies business acceptance.
267
262
  void action.ack().then(ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
268
263
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
264
+ // INTERACTION_CREATE's id is an event_id the reply may answer passively.
265
+ this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
269
266
  void this.emit('message.receive', {
270
267
  conversation, message: { conversation, id: action.message.id }, content: '',
271
268
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
@@ -330,6 +327,9 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
330
327
  #bot: QqHttpBotTransport | null = null;
331
328
  #open = false;
332
329
  readonly #seenInteractions = new Map<string, number>();
330
+ /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
331
+ readonly #passiveReplies = new QqPassiveReplyLedger();
332
+ readonly #activeGate = new QqActiveMessageGate();
333
333
  readonly #lifecycle: EndpointLifecycle;
334
334
  #cleanup?: () => Promise<void>;
335
335
  readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
@@ -432,23 +432,12 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
432
432
  const bot = this.#requireBot();
433
433
  let result: unknown;
434
434
  try {
435
- for (const message of messages) {
436
- switch (kind) {
437
- case 'private':
438
- result = await bot.sendPrivateMessage(conversation.id, message);
439
- break;
440
- case 'group':
441
- result = await bot.sendGroupMessage(conversation.id, message);
442
- break;
443
- case 'channel':
444
- result = await bot.sendGuildMessage(conversation.id, message);
445
- break;
446
- case 'direct':
447
- if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
448
- result = await bot.sendDirectMessage(conversation.id, message);
449
- break;
450
- }
451
- }
435
+ result = await sendQqMessages({
436
+ bot,
437
+ ledger: this.#passiveReplies,
438
+ activeGate: this.#activeGate,
439
+ log: fields => this.#logger.warn(formatCompact(fields)),
440
+ }, conversation, messages);
452
441
  } catch (error) {
453
442
  const { failure, diagnostic } = qqDeliveryFailure(error);
454
443
  this.#logger.warn(formatCompact(diagnostic));
@@ -486,6 +475,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
486
475
  + ` | ${truncatePreview(content, 80)}`,
487
476
  );
488
477
  const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
478
+ if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
489
479
  void this.emit('message.receive', {
490
480
  conversation,
491
481
  message: { conversation, id: msg.id },
@@ -528,6 +518,8 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
528
518
  // ACK owns only platform loading state, never implies business acceptance.
529
519
  void action.ack().then(ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#logger.warn('qq_interaction_ack_failed'));
530
520
  const conversation = qqInboundConversation(String(this.#options.id), action.message);
521
+ // INTERACTION_CREATE's id is an event_id the reply may answer passively.
522
+ this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
531
523
  void this.emit('message.receive', {
532
524
  conversation, message: { conversation, id: action.message.id }, content: '',
533
525
  segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
package/src/i18n.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { createMessages } from 'zhin.js/i18n';
2
+
3
+ export const messages = createMessages({
4
+ bindTaskFailed: { 'zh-CN': '获取绑定任务失败: {error}', en: 'Could not create the binding task: {error}' },
5
+ "kindPrompt": {
6
+ "zh-CN": "请回复 public(公域)或 private(私域),确认后一次性写入 .env 与配置。",
7
+ "en": "Reply public or private to save .env and configuration together."
8
+ },
9
+ "cancelHint": {
10
+ "zh-CN": "也可发 {command} 取消。",
11
+ "en": "Send {command} to cancel."
12
+ },
13
+ "bindingBusy": {
14
+ "zh-CN": "⚠️ 有进行中的扫码绑定,请先取消当前绑定流程",
15
+ "en": "⚠️ A QR binding is in progress; cancel it before starting another"
16
+ },
17
+ "kindBusy": {
18
+ "zh-CN": "⚠️ endpoint「{id}」等待公域/私域选择,请回复 public/private",
19
+ "en": "⚠️ Endpoint “{id}” is waiting for public/private selection"
20
+ },
21
+ "endpointSaved": {
22
+ "zh-CN": "✅ 已按 botKind={kind} 将 endpoint「{id}」写入 .env 与 {file}。\nintents: {intents}。\n⚠️ 需重启 zhin 后新 endpoint 才会生效。",
23
+ "en": "✅ Saved endpoint “{id}” with botKind={kind} to .env and {file}.\nintents: {intents}.\n⚠️ Restart zhin to activate the new endpoint."
24
+ },
25
+ "existingFlow": {
26
+ "zh-CN": "已有进行中的 QQ 机器人绑定或待确认的公域/私域选择,请先取消后再试",
27
+ "en": "A QQ binding or public/private selection is already in progress; cancel it before trying again"
28
+ },
29
+ "scanLink": {
30
+ "zh-CN": "请用手机 QQ 打开以下链接完成扫码绑定(二维码图片出站暂未支持,故发送链接):\n{url}",
31
+ "en": "Open this link in mobile QQ to scan and bind the bot:\n{url}"
32
+ },
33
+ "qrExpired": {
34
+ "zh-CN": "二维码已过期,正在刷新,请扫描新链接…",
35
+ "en": "The QR code expired. Refreshing; scan the new link…"
36
+ },
37
+ "noInteractiveKind": {
38
+ "zh-CN": "\n(当前会话无法交互选择公/私域;私域请手动改配置。)",
39
+ "en": "\n(This source cannot answer public/private selection. Edit the configuration manually for a private bot.)"
40
+ },
41
+ "scanned": {
42
+ "zh-CN": "✅ 扫码成功!endpoint「{id}」。\n{prompt}",
43
+ "en": "✅ QR binding succeeded for endpoint “{id}”.\n{prompt}"
44
+ },
45
+ "writeFailed": {
46
+ "zh-CN": "绑定成功但写入配置失败:{error}",
47
+ "en": "Binding succeeded but configuration could not be saved: {error}"
48
+ },
49
+ "bindingFailed": {
50
+ "zh-CN": "❌ QQ 绑定失败:{error}",
51
+ "en": "❌ QQ binding failed: {error}"
52
+ },
53
+ "cancelled": {
54
+ "zh-CN": "已取消进行中的 QQ 绑定流程",
55
+ "en": "The active QQ binding was cancelled"
56
+ },
57
+ "pendingCancelled": {
58
+ "zh-CN": "已取消 endpoint「{name}」的绑定(尚未写入任何文件)",
59
+ "en": "Binding for endpoint “{name}” cancelled; no files were written"
60
+ },
61
+ "noFlow": {
62
+ "zh-CN": "当前没有进行中的 QQ 绑定流程",
63
+ "en": "No QQ binding is in progress"
64
+ },
65
+ "kindUnrecognized": {
66
+ "zh-CN": "未识别「{raw}」。{prompt}",
67
+ "en": "Unrecognized answer “{raw}”. {prompt}"
68
+ },
69
+ "configFailed": {
70
+ "zh-CN": "写入配置失败:{error}",
71
+ "en": "Could not save configuration: {error}"
72
+ }
73
+ });
@@ -0,0 +1,159 @@
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
+
14
+ /** Reply-to source for the SDK: a user message id or an event id. */
15
+ export type QqReplySource = { readonly id: string } | { readonly event_id: string };
16
+
17
+ export interface QqPassiveLimit {
18
+ readonly windowMs: number;
19
+ readonly maxReplies: number;
20
+ }
21
+
22
+ /** C2C: 60 min, 4 replies. Group: 5 min, 5 replies. Guild channel and DM: 5 min (count unpublished; 5 assumed). */
23
+ export const QQ_PASSIVE_LIMITS: Readonly<Record<QqOutboundKind, QqPassiveLimit>> = Object.freeze({
24
+ private: { windowMs: 60 * 60_000, maxReplies: 4 },
25
+ group: { windowMs: 5 * 60_000, maxReplies: 5 },
26
+ channel: { windowMs: 5 * 60_000, maxReplies: 5 },
27
+ direct: { windowMs: 5 * 60_000, maxReplies: 5 },
28
+ });
29
+
30
+ /** Stop using a source this long before the platform window closes: the request itself takes time. */
31
+ const SAFETY_MARGIN_MS = 15_000;
32
+ /** Sources remembered per conversation; older ones are long expired in practice. */
33
+ const MAX_SOURCES_PER_CONVERSATION = 8;
34
+
35
+ interface SourceEntry {
36
+ readonly source: QqReplySource;
37
+ readonly receivedAt: number;
38
+ replies: number;
39
+ exhausted: boolean;
40
+ }
41
+
42
+ export interface PassiveReplyGrant {
43
+ readonly source: QqReplySource;
44
+ readonly msgSeq: number;
45
+ }
46
+
47
+ function sourceKey(source: QqReplySource): string {
48
+ return 'id' in source ? `msg:${source.id}` : `event:${source.event_id}`;
49
+ }
50
+
51
+ /** Expired conversations are dropped every this many records. */
52
+ const PRUNE_EVERY_RECORDS = 256;
53
+
54
+ export class QqPassiveReplyLedger {
55
+ readonly #conversations = new Map<string, SourceEntry[]>();
56
+ #records = 0;
57
+
58
+ constructor(private readonly now: () => number = Date.now) {}
59
+
60
+ /** A user message or event in `conversation` that later replies may answer. */
61
+ record(kind: QqOutboundKind, conversationId: string, source: QqReplySource, receivedAt = this.now()): void {
62
+ const key = `${kind}:${conversationId}`;
63
+ const entries = (this.#conversations.get(key) ?? []).filter(entry => sourceKey(entry.source) !== sourceKey(source));
64
+ entries.push({ source, receivedAt, replies: 0, exhausted: false });
65
+ entries.sort((a, b) => a.receivedAt - b.receivedAt);
66
+ this.#conversations.set(key, entries.slice(-MAX_SOURCES_PER_CONVERSATION));
67
+ if (++this.#records % PRUNE_EVERY_RECORDS === 0) this.prune();
68
+ }
69
+
70
+ /**
71
+ * Claim one reply: `preferred` (an explicit reply segment) when it is still usable, otherwise the
72
+ * newest usable source in the conversation. Undefined when only an active message is possible.
73
+ */
74
+ acquire(kind: QqOutboundKind, conversationId: string, preferred?: QqReplySource): PassiveReplyGrant | undefined {
75
+ const limit = QQ_PASSIVE_LIMITS[kind];
76
+ const now = this.now();
77
+ const entries = this.#conversations.get(`${kind}:${conversationId}`) ?? [];
78
+ const usable = (entry: SourceEntry) => !entry.exhausted
79
+ && entry.replies < limit.maxReplies
80
+ && now - entry.receivedAt < limit.windowMs - SAFETY_MARGIN_MS;
81
+ const preferredKey = preferred ? sourceKey(preferred) : undefined;
82
+ const entry = (preferredKey ? entries.find(item => sourceKey(item.source) === preferredKey && usable(item)) : undefined)
83
+ ?? [...entries].reverse().find(usable);
84
+ if (!entry) return undefined;
85
+ entry.replies += 1;
86
+ return { source: entry.source, msgSeq: entry.replies };
87
+ }
88
+
89
+ /** The platform said this source can no longer be replied to (expired, over the limit, invalid id). */
90
+ exhaust(kind: QqOutboundKind, conversationId: string, source: QqReplySource): void {
91
+ const key = sourceKey(source);
92
+ const entry = this.#conversations.get(`${kind}:${conversationId}`)?.find(item => sourceKey(item.source) === key);
93
+ if (entry) entry.exhausted = true;
94
+ }
95
+
96
+ /** Drop conversations whose every source is past its window. */
97
+ prune(): void {
98
+ const now = this.now();
99
+ for (const [key, entries] of this.#conversations) {
100
+ const kind = key.slice(0, key.indexOf(':')) as QqOutboundKind;
101
+ const windowMs = QQ_PASSIVE_LIMITS[kind]?.windowMs ?? 0;
102
+ const live = entries.filter(entry => now - entry.receivedAt < windowMs);
103
+ if (live.length === 0) this.#conversations.delete(key);
104
+ else this.#conversations.set(key, live);
105
+ }
106
+ }
107
+ }
108
+
109
+ /** Platform codes meaning the reply source is unusable: switch to an active message. */
110
+ export const QQ_PASSIVE_SOURCE_CODES: ReadonlySet<number> = new Set([
111
+ 40034128, // 被动回复时间或次数超限
112
+ 40034005, // 回复消息 msg_id 已过期
113
+ 304027, // MSG_EXPIRE 回复的消息过期
114
+ 304026, // MSG_ID 回复的消息 id 错误
115
+ 306002, // msgid error
116
+ 501005, 501008, 501009, // MessageID 错误 / 不存在 / 解析失败
117
+ ]);
118
+
119
+ /** Platform codes meaning active messages are unavailable for this conversation for now. */
120
+ export const QQ_ACTIVE_UNAVAILABLE_CODES: ReadonlySet<number> = new Set([
121
+ 40034105, // 主动消息发送失败,无权限
122
+ 304031, 304032, 304034, // 私信已关闭 / 不存在 / 不是私信成员
123
+ 304035, 304045, 304047, 304049, 304050, // 主动消息额度、频控
124
+ 304046, 304048, // 不允许在此(子)频道发主动消息
125
+ ]);
126
+
127
+ /** Platform codes meaning Markdown is not allowed: resend as plain text. */
128
+ export const QQ_MARKDOWN_DENIED_CODES: ReadonlySet<number> = new Set([
129
+ 304036, // 没有 markdown 模板的权限
130
+ 304014, // TEMPLATE_PRIVILEGE 没有模板权限
131
+ 50056, // 不允许发送 markdown content
132
+ ]);
133
+
134
+ /** How long a conversation is not offered active messages after the platform refused them. */
135
+ export const ACTIVE_UNAVAILABLE_BACKOFF_MS = 60 * 60_000;
136
+
137
+ /** Conversations where the platform recently refused active messages. */
138
+ export class QqActiveMessageGate {
139
+ readonly #blockedUntil = new Map<string, number>();
140
+
141
+ constructor(private readonly now: () => number = Date.now) {}
142
+
143
+ blocked(kind: QqOutboundKind, conversationId: string): boolean {
144
+ const key = `${kind}:${conversationId}`;
145
+ const until = this.#blockedUntil.get(key);
146
+ if (until === undefined) return false;
147
+ if (this.now() < until) return true;
148
+ this.#blockedUntil.delete(key);
149
+ return false;
150
+ }
151
+
152
+ /** Returns true the first time a conversation is blocked, so the caller can log it once. */
153
+ block(kind: QqOutboundKind, conversationId: string): boolean {
154
+ const key = `${kind}:${conversationId}`;
155
+ const fresh = !this.blocked(kind, conversationId);
156
+ this.#blockedUntil.set(key, this.now() + ACTIVE_UNAVAILABLE_BACKOFF_MS);
157
+ return fresh;
158
+ }
159
+ }
@@ -1,3 +1,5 @@
1
+ import type { Locale } from 'zhin.js/i18n';
2
+ import { messages as t } from './i18n.js';
1
3
  /**
2
4
  * QQ Bot 扫码绑定流程(内联,等价 qqbot-connector startQrConnect)
3
5
  */
@@ -25,6 +27,7 @@ export interface QqBindCallbacks {
25
27
  }
26
28
 
27
29
  export interface QqBindFlowOptions {
30
+ locale?: Locale;
28
31
  signal?: AbortSignal;
29
32
  source?: string;
30
33
  }
@@ -99,7 +102,7 @@ export function startQqBindFlow(
99
102
  task = await createBindTask();
100
103
  } catch (err) {
101
104
  throw new Error(
102
- `获取绑定任务失败: ${err instanceof Error ? err.message : String(err)}`,
105
+ t(options.locale, 'bindTaskFailed', { error: err instanceof Error ? err.message : String(err) }),
103
106
  { cause: err },
104
107
  );
105
108
  }
@@ -1,47 +1,32 @@
1
+ import { conversationRefKey } from '@zhin.js/im-contract';
2
+ import { createDeliveryTarget } from 'zhin.js';
3
+ import { resolveLocale, type Locale } from 'zhin.js/i18n';
4
+ import { messages as t } from './i18n.js';
1
5
  import { parseQqBotKind, type QqBotKind } from './qq-intents.js';
2
6
 
3
- /** 从命令/中间件入站消息提取会话键;无法识别时返回 undefined。 */
7
+ /** Bind replies to the complete canonical conversation and actual sender. */
4
8
  export function qqCommandSessionKey(input: unknown): string | undefined {
5
- const message = input as {
6
- conversation?: {
7
- endpoint?: { id?: unknown; adapter?: unknown };
8
- kind?: unknown;
9
- id?: unknown;
10
- };
11
- sender?: unknown;
12
- } | null | undefined;
13
- const conversation = message?.conversation;
14
- const adapter = conversation?.endpoint?.adapter;
15
- const endpointKey = conversation?.endpoint?.id;
16
- const kind = conversation?.kind;
17
- const id = conversation?.id;
18
- const sender = message?.sender;
19
- if (
20
- adapter == null
21
- || endpointKey == null
22
- || (kind !== 'private' && kind !== 'group' && kind !== 'channel')
23
- || typeof id !== 'string'
24
- || !id
25
- || sender == null
26
- || String(sender).trim() === ''
27
- ) {
28
- return undefined;
29
- }
30
- return `${String(adapter)}\0${String(endpointKey)}\0${kind}\0${id}\0${String(sender).trim()}`;
9
+ if (!input || typeof input !== 'object') return undefined;
10
+ const message = input as { conversation?: unknown; sender?: { id?: unknown } };
11
+ if (typeof message.sender?.id !== 'string' || !message.sender.id.trim()) return undefined;
12
+ try {
13
+ const conversation = createDeliveryTarget({ conversation: message.conversation as Parameters<typeof createDeliveryTarget>[0]['conversation'] });
14
+ return JSON.stringify([conversationRefKey(conversation), message.sender.id]);
15
+ } catch { return undefined; }
31
16
  }
32
17
 
33
- /**
34
- * 解析用户对公域/私域的回复。
35
- * 接受:public / private / 公域 / 私域(大小写不敏感,可带前后空白)。
36
- */
37
- export function parseQqBotKindAnswer(raw: unknown): QqBotKind | undefined {
18
+ /** Public/private are stable botKind values; Chinese aliases apply only to Chinese flows. */
19
+ export function parseQqBotKindAnswer(raw: unknown, locale?: Locale): QqBotKind | undefined {
38
20
  const text = String(raw ?? '').trim().toLowerCase();
39
21
  if (!text) return undefined;
40
- if (text === '公域' || text === '公开' || text === '1') return 'public';
41
- if (text === '私域' || text === '2') return 'private';
22
+ if (resolveLocale(locale) === 'zh-CN') {
23
+ if (text === '公域' || text === '公开' || text === '1') return 'public';
24
+ if (text === '私域' || text === '2') return 'private';
25
+ }
42
26
  return parseQqBotKind(text);
43
27
  }
44
28
 
45
- export const QQ_BOT_KIND_PROMPT =
46
- '请回复 public(公域)或 private(私域),确认后一次性写入 .env 与配置。\n'
47
- + '也可发 qq endpoint cancel 取消。';
29
+ export function qqBotKindPrompt(locale?: Locale, cancelCommand?: string): string {
30
+ return t(locale, 'kindPrompt') + (cancelCommand ? '\n' + t(locale, 'cancelHint', { command: cancelCommand }) : '');
31
+ }
32
+ export const QQ_BOT_KIND_PROMPT = qqBotKindPrompt();
@@ -14,6 +14,9 @@ export interface QqBindFlowHandle {
14
14
  /** 绑定期望的 endpoint id(未指定时为 undefined,成功后取 appId) */
15
15
  id?: string;
16
16
  stop: () => void;
17
+ readonly locale?: import('zhin.js/i18n').Locale;
18
+ readonly cancelCommand?: string;
19
+ readonly commandHeads?: readonly string[];
17
20
  }
18
21
 
19
22
  /**
@@ -26,6 +29,9 @@ export interface QqPendingBotKind {
26
29
  readonly appSecret: string;
27
30
  /** 发起 add 的会话键;仅同会话回复可完成选择 */
28
31
  readonly sessionKey: string;
32
+ readonly locale?: import('zhin.js/i18n').Locale;
33
+ readonly cancelCommand?: string;
34
+ readonly commandHeads?: readonly string[];
29
35
  }
30
36
 
31
37
  export interface QqRuntimeState {
package/src/qq-send.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * One QQ outbound send: passive reply when a usable source exists, active message otherwise, with
3
+ * the fallbacks the platform's error codes allow. Shared by the websocket and webhook Endpoints.
4
+ *
5
+ * 1. Each message claims a passive source (explicit reply segment first, else the conversation's
6
+ * newest usable msg_id / event_id) with its own msg_seq.
7
+ * 2. Source unusable (expired, over the limit, invalid id): mark it and send that message once as
8
+ * an active message. Never after a timeout: the first request may have been delivered.
9
+ * 3. Markdown not allowed: resend the same message as plain text.
10
+ * 4. Active message refused (no permission, quota, user switched it off): remember the conversation
11
+ * for a while and fail fast instead of calling the platform again.
12
+ */
13
+ import type { ConversationRef } from '@zhin.js/im-contract';
14
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
15
+ import type { QqOutboundElem, QqOutboundMessage } from './outbound.js';
16
+ import {
17
+ QQ_ACTIVE_UNAVAILABLE_CODES,
18
+ QQ_MARKDOWN_DENIED_CODES,
19
+ QQ_PASSIVE_SOURCE_CODES,
20
+ type PassiveReplyGrant,
21
+ type QqActiveMessageGate,
22
+ type QqPassiveReplyLedger,
23
+ type QqReplySource,
24
+ } from './passive-reply.js';
25
+ import { qqDeliveryFailure } from './delivery-error.js';
26
+ import { qqOutboundKind } from './protocol.js';
27
+ import type { QqBotTransport, QqSendOptions } from './ws.js';
28
+
29
+ export interface QqSendContext {
30
+ readonly bot: QqBotTransport;
31
+ readonly ledger: QqPassiveReplyLedger;
32
+ readonly activeGate: QqActiveMessageGate;
33
+ readonly log: (fields: Record<string, string | number | undefined>) => void;
34
+ }
35
+
36
+ type Kind = ReturnType<typeof qqOutboundKind>;
37
+
38
+ function elements(message: QqOutboundMessage): Array<string | QqOutboundElem> {
39
+ return Array.isArray(message) ? [...message] : [message as string | QqOutboundElem];
40
+ }
41
+
42
+ /** The reply segment the payload already names, as a passive source. */
43
+ export function explicitReplySource(message: QqOutboundMessage): QqReplySource | undefined {
44
+ for (const part of elements(message)) {
45
+ if (typeof part === 'string' || part.type !== 'reply') continue;
46
+ if (typeof part.data.event_id === 'string' && part.data.event_id) return { event_id: part.data.event_id };
47
+ if (typeof part.data.id === 'string' && part.data.id) return { id: part.data.id };
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ /** Plain-text version of a Markdown message (buttons dropped); undefined when it has no Markdown. */
53
+ export function markdownAsText(message: QqOutboundMessage): QqOutboundMessage | undefined {
54
+ const parts = elements(message);
55
+ if (!parts.some(part => typeof part !== 'string' && (part.type === 'markdown' || part.type === 'keyboard' || part.type === 'button'))) return undefined;
56
+ const text = parts.map(part => {
57
+ if (typeof part === 'string') return part;
58
+ if (part.type === 'text') return String(part.data.text ?? '');
59
+ if (part.type === 'markdown') {
60
+ return String(part.data.content ?? '')
61
+ .replace(/!\[[^\]\n]{0,500}\]\(([^)\s]{1,2000})\)/g, '$1')
62
+ .replace(/\[([^\]\n]{1,500})\]\(([^)\s]{1,2000})\)/g, '$1 $2')
63
+ .replace(/^#{1,6}\s+/gm, '')
64
+ .replace(/(\*\*|__|~~|`)/g, '');
65
+ }
66
+ return '';
67
+ }).join('').trim();
68
+ const kept = parts.filter((part): part is QqOutboundElem => typeof part !== 'string' && (part.type === 'reply' || part.type === 'at'));
69
+ return text ? [...kept, text] : undefined;
70
+ }
71
+
72
+ function dispatch(bot: QqBotTransport, kind: Kind, id: string, message: QqOutboundMessage, source?: QqReplySource, options?: QqSendOptions): Promise<unknown> {
73
+ switch (kind) {
74
+ case 'private': return bot.sendPrivateMessage(id, message, source, options);
75
+ case 'group': return bot.sendGroupMessage(id, message, source, options);
76
+ case 'channel': return bot.sendGuildMessage(id, message, source, options);
77
+ case 'direct':
78
+ if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
79
+ return bot.sendDirectMessage(id, message, source, options);
80
+ }
81
+ }
82
+
83
+ function platformCode(error: unknown): number | undefined {
84
+ return qqDeliveryFailure(error).diagnostic.platformCode;
85
+ }
86
+
87
+ /** Sends one message, passive if possible; returns the platform result. */
88
+ async function sendOne(context: QqSendContext, kind: Kind, id: string, message: QqOutboundMessage): Promise<unknown> {
89
+ const passive = (body: QqOutboundMessage, grant: PassiveReplyGrant) =>
90
+ dispatch(context.bot, kind, id, body, grant.source, { msgSeq: grant.msgSeq });
91
+ const active = async (body: QqOutboundMessage): Promise<unknown> => {
92
+ if (context.activeGate.blocked(kind, id)) {
93
+ throw new EndpointDeliveryError('platform_rejected', 'QQ active messages are unavailable for this conversation', 'not_sent');
94
+ }
95
+ try {
96
+ return await dispatch(context.bot, kind, id, body);
97
+ } catch (error) {
98
+ const code = platformCode(error);
99
+ if (code !== undefined && QQ_ACTIVE_UNAVAILABLE_CODES.has(code) && context.activeGate.block(kind, id)) {
100
+ context.log({ op: 'qq_active_unavailable', target: `${kind}:${id}`, platformCode: code });
101
+ }
102
+ throw error;
103
+ }
104
+ };
105
+ /** Markdown refused: the same message once more as plain text, under a fresh msg_seq when passive. */
106
+ const asText = (error: unknown, previous: PassiveReplyGrant | undefined): Promise<unknown> => {
107
+ const code = platformCode(error);
108
+ const text = code !== undefined && QQ_MARKDOWN_DENIED_CODES.has(code) ? markdownAsText(message) : undefined;
109
+ if (!text) throw error;
110
+ context.log({ op: 'qq_markdown_denied_as_text', target: `${kind}:${id}`, platformCode: code });
111
+ const retry = previous ? context.ledger.acquire(kind, id, previous.source) : undefined;
112
+ return retry ? passive(text, retry) : active(text);
113
+ };
114
+ const activeOrText = (body: QqOutboundMessage) => active(body).catch((error: unknown) => asText(error, undefined));
115
+
116
+ const grant = context.ledger.acquire(kind, id, explicitReplySource(message));
117
+ if (!grant) return activeOrText(message);
118
+ try {
119
+ return await passive(message, grant);
120
+ } catch (error) {
121
+ const code = platformCode(error);
122
+ if (code !== undefined && QQ_PASSIVE_SOURCE_CODES.has(code)) {
123
+ context.ledger.exhaust(kind, id, grant.source);
124
+ context.log({ op: 'qq_passive_source_unusable', target: `${kind}:${id}`, platformCode: code });
125
+ return activeOrText(message);
126
+ }
127
+ // Anything else, including a timeout (outcome unknown), is not retried as an active message.
128
+ return asText(error, grant);
129
+ }
130
+ }
131
+
132
+ /** Sends every message of one delivery in order; returns the last platform result. */
133
+ export async function sendQqMessages(context: QqSendContext, conversation: ConversationRef, messages: readonly QqOutboundMessage[]): Promise<unknown> {
134
+ const kind = qqOutboundKind(conversation);
135
+ let result: unknown;
136
+ for (const message of messages) result = await sendOne(context, kind, conversation.id, message);
137
+ return result;
138
+ }
package/src/webhook.ts CHANGED
@@ -100,10 +100,10 @@ export function defaultCreateHttpBot(config: ResolvedQqHttpConfig): QqHttpBotTra
100
100
  removeAllListeners: () => {
101
101
  bot.removeAllListeners(undefined as never);
102
102
  },
103
- sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message as Sendable),
104
- sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message as Sendable),
105
- sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message as Sendable),
106
- sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message as Sendable),
103
+ sendPrivateMessage: (userId, message, source, options) => bot.sendPrivateMessage(userId, message as Sendable, source, options),
104
+ sendGroupMessage: (groupId, message, source, options) => bot.sendGroupMessage(groupId, message as Sendable, source, options),
105
+ sendGuildMessage: (channelId, message, source, options) => bot.sendGuildMessage(channelId, message as Sendable, source, options),
106
+ sendDirectMessage: (guildId, message, source, options) => bot.sendDirectMessage(guildId, message as Sendable, source, options),
107
107
  recallPrivateMessage: (userId, messageId) => bot.recallPrivateMessage(userId, messageId),
108
108
  recallGroupMessage: (groupId, messageId) => bot.recallGroupMessage(groupId, messageId),
109
109
  recallGuildMessage: (channelId, messageId) => bot.recallGuildMessage(channelId, messageId),