@zxh126/pi-qqbot 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/progress.ts CHANGED
@@ -1,176 +1,176 @@
1
- /**
2
- * 任务进度摘要:把 run 期间的事件(轮次、工具调用、流式文本、token)压成一条
3
- * 适合发到 QQ 的短消息。纯本地字符串拼接,不产生任何模型调用。
4
- */
5
-
6
- import { redactSecrets, shortRedacted } from "./redact";
7
-
8
- export interface ProgressSettings {
9
- /** 是否自动推送进度 */
10
- enabled: boolean;
11
- /** 推送间隔(毫秒) */
12
- intervalMs: number;
13
- }
14
-
15
- const MAX_PROGRESS_CHARS = 320;
16
- const TOOL_PREVIEW_CHARS = 60;
17
- const TAIL_CHARS = 120;
18
-
19
- /** 把工具参数压成一句人能看懂的话(已脱敏、已截断) */
20
- export function toolPreview(toolName: string, args: any): string {
21
- const input = args ?? {};
22
- switch (toolName) {
23
- case "bash":
24
- case "powershell":
25
- return shortRedacted(input.command ?? "");
26
- case "write":
27
- case "edit":
28
- case "read":
29
- return shortRedacted(input.path ?? input.file ?? input.filePath ?? "");
30
- case "grep":
31
- case "bgrep":
32
- return shortRedacted(input.pattern ?? "");
33
- case "glob":
34
- case "find":
35
- return shortRedacted(input.pattern ?? input.path ?? "");
36
- default:
37
- try {
38
- return shortRedacted(JSON.stringify(input));
39
- } catch {
40
- return shortRedacted(String(input));
41
- }
42
- }
43
- }
44
-
45
- const fmtDuration = (ms: number): string => {
46
- const total = Math.max(0, Math.round(ms / 1000));
47
- const h = Math.floor(total / 3600);
48
- const m = Math.floor((total % 3600) / 60);
49
- const s = total % 60;
50
- if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
51
- return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
52
- };
53
-
54
- const fmtTokens = (n: number): string =>
55
- n >= 1_000_000 ? `${(n / 1_000_000).toFixed(2)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n);
56
-
57
- interface OpenTool {
58
- name: string;
59
- preview: string;
60
- startedAt: number;
61
- }
62
-
63
- export class ProgressTracker {
64
- running = false;
65
- private startedAt = 0;
66
- private turns = 0;
67
- private counts = new Map<string, number>();
68
- private failures = 0;
69
- private tokens = 0;
70
- private tail = "";
71
- private open = new Map<string, OpenTool>();
72
- private last: { name: string; preview: string; ms?: number; ok?: boolean } | null = null;
73
-
74
- start(now = Date.now()): void {
75
- this.running = true;
76
- this.startedAt = now;
77
- this.turns = 0;
78
- this.counts.clear();
79
- this.failures = 0;
80
- this.tokens = 0;
81
- this.tail = "";
82
- this.open.clear();
83
- this.last = null;
84
- }
85
-
86
- stop(): void {
87
- this.running = false;
88
- this.open.clear();
89
- this.tail = "";
90
- }
91
-
92
- toolStart(callId: string, toolName: string, args: any, now = Date.now()): void {
93
- if (!this.running) return;
94
- const name = toolName || "tool";
95
- this.open.set(callId, { name, preview: toolPreview(name, args), startedAt: now });
96
- this.counts.set(name, (this.counts.get(name) ?? 0) + 1);
97
- this.tail = "";
98
- }
99
-
100
- toolEnd(callId: string, toolName: string, isError: boolean, durationMs?: number, now = Date.now()): void {
101
- if (!this.running) return;
102
- const open = this.open.get(callId);
103
- this.open.delete(callId);
104
- if (isError) this.failures += 1;
105
- this.last = {
106
- name: open?.name ?? toolName ?? "tool",
107
- preview: open?.preview ?? "",
108
- ms: typeof durationMs === "number" ? durationMs : open ? now - open.startedAt : undefined,
109
- ok: !isError,
110
- };
111
- }
112
-
113
- turn(): void {
114
- if (this.running) this.turns += 1;
115
- }
116
-
117
- addUsage(usage: any): void {
118
- if (!this.running) return;
119
- const total = Number(usage?.totalTokens ?? 0);
120
- if (Number.isFinite(total) && total > 0) this.tokens += total;
121
- }
122
-
123
- appendText(delta: string): void {
124
- if (!this.running || !delta) return;
125
- this.tail = `${this.tail}${delta}`.slice(-TAIL_CHARS * 2);
126
- }
127
-
128
- /** 当前正在执行的工具(如果卡在某个长命令上,这比「正在写」有用) */
129
- private runningTool(): OpenTool | null {
130
- let newest: OpenTool | null = null;
131
- for (const tool of this.open.values()) {
132
- if (!newest || tool.startedAt > newest.startedAt) newest = tool;
133
- }
134
- return newest;
135
- }
136
-
137
- /** 一条可直接发到 QQ 的摘要;没有可说的内容时返回空串 */
138
- summary(now = Date.now()): string {
139
- if (!this.running) return "";
140
- const head: string[] = [`[进度 ${fmtDuration(now - this.startedAt)}]`];
141
- if (this.turns > 0) head.push(`第 ${this.turns} 轮`);
142
-
143
- const totalCalls = [...this.counts.values()].reduce((sum, n) => sum + n, 0);
144
- if (totalCalls > 0) {
145
- const top = [...this.counts.entries()]
146
- .sort((a, b) => b[1] - a[1])
147
- .slice(0, 3)
148
- .map(([name, n]) => `${name} ${n}`)
149
- .join(" / ");
150
- head.push(`工具 ${totalCalls} 次(${top})`);
151
- }
152
- if (this.failures > 0) head.push(`失败 ${this.failures}`);
153
-
154
- const lines: string[] = [head.join(" · ")];
155
-
156
- if (this.last) {
157
- const timing = this.last.ms !== undefined ? ` · ${(this.last.ms / 1000).toFixed(1)}s` : "";
158
- const status = this.last.ok === false ? " · 失败" : "";
159
- const preview = this.last.preview ? ` "${this.last.preview}"` : "";
160
- lines.push(`最近: ${this.last.name}${preview}${timing}${status}`);
161
- }
162
-
163
- const active = this.runningTool();
164
- if (active) {
165
- lines.push(`正在: ${active.name}${active.preview ? ` "${active.preview}"` : ""}(已 ${(Math.max(0, now - active.startedAt) / 1000).toFixed(0)}s)`);
166
- } else {
167
- const tail = shortRedacted(this.tail.slice(-TAIL_CHARS), TAIL_CHARS);
168
- if (tail) lines.push(`正在: ${tail}`);
169
- }
170
-
171
- if (this.tokens > 0) lines.push(`已用 ${fmtTokens(this.tokens)} tok`);
172
-
173
- const text = redactSecrets(lines.join("\n"));
174
- return text.length > MAX_PROGRESS_CHARS ? `${text.slice(0, MAX_PROGRESS_CHARS)}…` : text;
175
- }
176
- }
1
+ /**
2
+ * 任务进度摘要:把 run 期间的事件(轮次、工具调用、流式文本、token)压成一条
3
+ * 适合发到 QQ 的短消息。纯本地字符串拼接,不产生任何模型调用。
4
+ */
5
+
6
+ import { redactSecrets, shortRedacted } from "./redact";
7
+
8
+ export interface ProgressSettings {
9
+ /** 是否自动推送进度 */
10
+ enabled: boolean;
11
+ /** 推送间隔(毫秒) */
12
+ intervalMs: number;
13
+ }
14
+
15
+ const MAX_PROGRESS_CHARS = 320;
16
+ const TOOL_PREVIEW_CHARS = 60;
17
+ const TAIL_CHARS = 120;
18
+
19
+ /** 把工具参数压成一句人能看懂的话(已脱敏、已截断) */
20
+ export function toolPreview(toolName: string, args: any): string {
21
+ const input = args ?? {};
22
+ switch (toolName) {
23
+ case "bash":
24
+ case "powershell":
25
+ return shortRedacted(input.command ?? "");
26
+ case "write":
27
+ case "edit":
28
+ case "read":
29
+ return shortRedacted(input.path ?? input.file ?? input.filePath ?? "");
30
+ case "grep":
31
+ case "bgrep":
32
+ return shortRedacted(input.pattern ?? "");
33
+ case "glob":
34
+ case "find":
35
+ return shortRedacted(input.pattern ?? input.path ?? "");
36
+ default:
37
+ try {
38
+ return shortRedacted(JSON.stringify(input));
39
+ } catch {
40
+ return shortRedacted(String(input));
41
+ }
42
+ }
43
+ }
44
+
45
+ const fmtDuration = (ms: number): string => {
46
+ const total = Math.max(0, Math.round(ms / 1000));
47
+ const h = Math.floor(total / 3600);
48
+ const m = Math.floor((total % 3600) / 60);
49
+ const s = total % 60;
50
+ if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
51
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
52
+ };
53
+
54
+ const fmtTokens = (n: number): string =>
55
+ n >= 1_000_000 ? `${(n / 1_000_000).toFixed(2)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n);
56
+
57
+ interface OpenTool {
58
+ name: string;
59
+ preview: string;
60
+ startedAt: number;
61
+ }
62
+
63
+ export class ProgressTracker {
64
+ running = false;
65
+ private startedAt = 0;
66
+ private turns = 0;
67
+ private counts = new Map<string, number>();
68
+ private failures = 0;
69
+ private tokens = 0;
70
+ private tail = "";
71
+ private open = new Map<string, OpenTool>();
72
+ private last: { name: string; preview: string; ms?: number; ok?: boolean } | null = null;
73
+
74
+ start(now = Date.now()): void {
75
+ this.running = true;
76
+ this.startedAt = now;
77
+ this.turns = 0;
78
+ this.counts.clear();
79
+ this.failures = 0;
80
+ this.tokens = 0;
81
+ this.tail = "";
82
+ this.open.clear();
83
+ this.last = null;
84
+ }
85
+
86
+ stop(): void {
87
+ this.running = false;
88
+ this.open.clear();
89
+ this.tail = "";
90
+ }
91
+
92
+ toolStart(callId: string, toolName: string, args: any, now = Date.now()): void {
93
+ if (!this.running) return;
94
+ const name = toolName || "tool";
95
+ this.open.set(callId, { name, preview: toolPreview(name, args), startedAt: now });
96
+ this.counts.set(name, (this.counts.get(name) ?? 0) + 1);
97
+ this.tail = "";
98
+ }
99
+
100
+ toolEnd(callId: string, toolName: string, isError: boolean, durationMs?: number, now = Date.now()): void {
101
+ if (!this.running) return;
102
+ const open = this.open.get(callId);
103
+ this.open.delete(callId);
104
+ if (isError) this.failures += 1;
105
+ this.last = {
106
+ name: open?.name ?? toolName ?? "tool",
107
+ preview: open?.preview ?? "",
108
+ ms: typeof durationMs === "number" ? durationMs : open ? now - open.startedAt : undefined,
109
+ ok: !isError,
110
+ };
111
+ }
112
+
113
+ turn(): void {
114
+ if (this.running) this.turns += 1;
115
+ }
116
+
117
+ addUsage(usage: any): void {
118
+ if (!this.running) return;
119
+ const total = Number(usage?.totalTokens ?? 0);
120
+ if (Number.isFinite(total) && total > 0) this.tokens += total;
121
+ }
122
+
123
+ appendText(delta: string): void {
124
+ if (!this.running || !delta) return;
125
+ this.tail = `${this.tail}${delta}`.slice(-TAIL_CHARS * 2);
126
+ }
127
+
128
+ /** 当前正在执行的工具(如果卡在某个长命令上,这比「正在写」有用) */
129
+ private runningTool(): OpenTool | null {
130
+ let newest: OpenTool | null = null;
131
+ for (const tool of this.open.values()) {
132
+ if (!newest || tool.startedAt > newest.startedAt) newest = tool;
133
+ }
134
+ return newest;
135
+ }
136
+
137
+ /** 一条可直接发到 QQ 的摘要;没有可说的内容时返回空串 */
138
+ summary(now = Date.now()): string {
139
+ if (!this.running) return "";
140
+ const head: string[] = [`[进度 ${fmtDuration(now - this.startedAt)}]`];
141
+ if (this.turns > 0) head.push(`第 ${this.turns} 轮`);
142
+
143
+ const totalCalls = [...this.counts.values()].reduce((sum, n) => sum + n, 0);
144
+ if (totalCalls > 0) {
145
+ const top = [...this.counts.entries()]
146
+ .sort((a, b) => b[1] - a[1])
147
+ .slice(0, 3)
148
+ .map(([name, n]) => `${name} ${n}`)
149
+ .join(" / ");
150
+ head.push(`工具 ${totalCalls} 次(${top})`);
151
+ }
152
+ if (this.failures > 0) head.push(`失败 ${this.failures}`);
153
+
154
+ const lines: string[] = [head.join(" · ")];
155
+
156
+ if (this.last) {
157
+ const timing = this.last.ms !== undefined ? ` · ${(this.last.ms / 1000).toFixed(1)}s` : "";
158
+ const status = this.last.ok === false ? " · 失败" : "";
159
+ const preview = this.last.preview ? ` "${this.last.preview}"` : "";
160
+ lines.push(`最近: ${this.last.name}${preview}${timing}${status}`);
161
+ }
162
+
163
+ const active = this.runningTool();
164
+ if (active) {
165
+ lines.push(`正在: ${active.name}${active.preview ? ` "${active.preview}"` : ""}(已 ${(Math.max(0, now - active.startedAt) / 1000).toFixed(0)}s)`);
166
+ } else {
167
+ const tail = shortRedacted(this.tail.slice(-TAIL_CHARS), TAIL_CHARS);
168
+ if (tail) lines.push(`正在: ${tail}`);
169
+ }
170
+
171
+ if (this.tokens > 0) lines.push(`已用 ${fmtTokens(this.tokens)} tok`);
172
+
173
+ const text = redactSecrets(lines.join("\n"));
174
+ return text.length > MAX_PROGRESS_CHARS ? `${text.slice(0, MAX_PROGRESS_CHARS)}…` : text;
175
+ }
176
+ }
package/qq/api.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { MAX_REPLIES_PER_MSG_ID } from "../constants";
2
+ import type { InlineKeyboard } from "./keyboard";
2
3
 
3
4
  export interface QQApiOptions {
4
5
  restBase: string;
@@ -9,6 +10,8 @@ export interface QQApiOptions {
9
10
  export interface SendOptions {
10
11
  msgId?: string;
11
12
  format?: "text" | "markdown";
13
+ /** 内联键盘:仅随 markdown 消息发送(官方要求),text 格式或回退时自动丢弃 */
14
+ keyboard?: InlineKeyboard;
12
15
  }
13
16
 
14
17
  export interface MediaSendOptions {
@@ -78,6 +81,18 @@ export class QQApi {
78
81
  await this.postWithRetry(openid, body);
79
82
  }
80
83
 
84
+ /** 回应按钮互动(INTERACTION_CREATE):平台要求 5 秒内 ACK,否则客户端一直 loading */
85
+ async acknowledgeInteraction(interactionId: string, code: number): Promise<void> {
86
+ const fetchFn = this.opts.fetchFn ?? fetch;
87
+ const token = await this.opts.getToken();
88
+ const res = await fetchFn(`${this.opts.restBase}/interactions/${interactionId}`, {
89
+ method: "PUT",
90
+ headers: { "Content-Type": "application/json", Authorization: `QQBot ${token}` },
91
+ body: JSON.stringify({ code }),
92
+ });
93
+ if (!res.ok) throw new Error(`acknowledgeInteraction failed: HTTP ${res.status} ${await res.text()}`);
94
+ }
95
+
81
96
  private async doSend(openid: string, content: string, options: SendOptions): Promise<void> {
82
97
  const format = options.format ?? "text";
83
98
  let msgId = options.msgId;
@@ -92,6 +107,8 @@ export class QQApi {
92
107
  body.msg_id = msgId;
93
108
  body.msg_seq = seq;
94
109
  }
110
+ // 键盘只随 markdown 发送;回退 text 时自然不带(fmt === "text" 不挂)
111
+ if (fmt === "markdown" && options.keyboard) body.keyboard = options.keyboard;
95
112
  return body;
96
113
  };
97
114
  try {
package/qq/auth.ts CHANGED
@@ -1,35 +1,35 @@
1
- import { TOKEN_URL } from "../constants";
2
-
3
- export class AuthManager {
4
- private token: string | null = null;
5
- private expireAt = 0;
6
-
7
- constructor(
8
- private appId: string,
9
- private appSecret: string,
10
- private fetchFn: typeof fetch = fetch,
11
- ) {}
12
-
13
- async getToken(): Promise<string> {
14
- const now = Date.now();
15
- if (this.token && now < this.expireAt - 60_000) return this.token;
16
- const res = await this.fetchFn(TOKEN_URL, {
17
- method: "POST",
18
- headers: { "Content-Type": "application/json" },
19
- body: JSON.stringify({ appId: this.appId, clientSecret: this.appSecret }),
20
- });
21
- if (!res.ok) throw new Error(`getAppAccessToken failed: HTTP ${res.status}`);
22
- const data = (await res.json()) as any;
23
- const token = String(data.access_token ?? "");
24
- // QQ 平台对无效凭据返回 HTTP 200 + code 100016,以前只在后面拿空 token 去连网关,
25
- // 表现为「锁抢到了但永远连不上」;这里直接抛,抢占阶段就能发现。
26
- if (!token) {
27
- const code = data?.code ?? "-";
28
- const message = data?.message ?? data?.msg ?? "未返回 access_token";
29
- throw new Error(`getAppAccessToken 被拒: code=${code} ${message}`);
30
- }
31
- this.token = token;
32
- this.expireAt = Date.now() + Number(data.expires_in ?? 0) * 1000;
33
- return this.token;
34
- }
35
- }
1
+ import { TOKEN_URL } from "../constants";
2
+
3
+ export class AuthManager {
4
+ private token: string | null = null;
5
+ private expireAt = 0;
6
+
7
+ constructor(
8
+ private appId: string,
9
+ private appSecret: string,
10
+ private fetchFn: typeof fetch = fetch,
11
+ ) {}
12
+
13
+ async getToken(): Promise<string> {
14
+ const now = Date.now();
15
+ if (this.token && now < this.expireAt - 60_000) return this.token;
16
+ const res = await this.fetchFn(TOKEN_URL, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ appId: this.appId, clientSecret: this.appSecret }),
20
+ });
21
+ if (!res.ok) throw new Error(`getAppAccessToken failed: HTTP ${res.status}`);
22
+ const data = (await res.json()) as any;
23
+ const token = String(data.access_token ?? "");
24
+ // QQ 平台对无效凭据返回 HTTP 200 + code 100016,以前只在后面拿空 token 去连网关,
25
+ // 表现为「锁抢到了但永远连不上」;这里直接抛,抢占阶段就能发现。
26
+ if (!token) {
27
+ const code = data?.code ?? "-";
28
+ const message = data?.message ?? data?.msg ?? "未返回 access_token";
29
+ throw new Error(`getAppAccessToken 被拒: code=${code} ${message}`);
30
+ }
31
+ this.token = token;
32
+ this.expireAt = Date.now() + Number(data.expires_in ?? 0) * 1000;
33
+ return this.token;
34
+ }
35
+ }
package/qq/gateway.ts CHANGED
@@ -27,13 +27,24 @@ export interface QQMessage {
27
27
  quotedText?: string;
28
28
  }
29
29
 
30
- export interface QQGatewayOptions {
30
+ /** 按钮点击回调(INTERACTION_CREATE)*/
31
+ export interface QQInteractionEvent {
32
+ id?: string;
33
+ scene?: string;
34
+ user_openid?: string;
35
+ group_openid?: string;
36
+ group_member_openid?: string;
37
+ data?: { resolved?: { button_data?: string; button_id?: string } };
38
+ }
39
+
40
+ export interface QQGatewayOptions {
31
41
  getGatewayUrl: () => Promise<string>;
32
42
  getToken: () => Promise<string>;
33
43
  intents: number;
34
44
  connected: () => void;
35
45
  disconnected: () => void;
36
46
  message: (msg: QQMessage) => Promise<void> | void;
47
+ interaction: (event: QQInteractionEvent) => void | Promise<void>;
37
48
  reconnectBaseMs?: number;
38
49
  maxSeen?: number;
39
50
  }
@@ -154,8 +165,10 @@ export class QQGateway {
154
165
  case OP_DISPATCH: {
155
166
  this.lastSeq = pkt.s ?? this.lastSeq;
156
167
  const d = pkt.d ?? {};
157
- if (this.isDuplicate(pkt.id ?? String(d.msg_id ?? d.id ?? ""))) return;
158
- this.handleDispatch(pkt.t ?? "", d);
168
+ const t = pkt.t ?? "";
169
+ // interaction 有 5 秒 ACK 要求:平台重投时也必须再回应,不能按消息去重
170
+ if (t !== "INTERACTION_CREATE" && this.isDuplicate(pkt.id ?? String(d.msg_id ?? d.id ?? ""))) return;
171
+ this.handleDispatch(t, d);
159
172
  break;
160
173
  }
161
174
  }
@@ -205,6 +218,10 @@ export class QQGateway {
205
218
  this.opts.connected();
206
219
  return;
207
220
  }
221
+ if (t === "INTERACTION_CREATE") {
222
+ this.opts.interaction(d as QQInteractionEvent);
223
+ return;
224
+ }
208
225
  if (t === "C2C_MESSAGE_CREATE") {
209
226
  const rawAttachments = Array.isArray(d.attachments) ? d.attachments : [];
210
227
  const attachments = rawAttachments
package/qq/keyboard.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * QQ 内联键盘(消息按钮)构建与 button_data 编解码。
3
+ *
4
+ * 官方规则(bot.q.qq.com「消息按钮」文档):
5
+ * - 键盘只随 markdown 消息(msg_type=2)发送;
6
+ * - 最多 5 行 × 每行 5 个按钮;
7
+ * - action.type=1 为回调按钮:data 原样回传给后台(INTERACTION_CREATE 事件);
8
+ * - 自定义按钮需平台「内邀开通」;发送失败时调用方应降级纯文本(编号回复仍可用)。
9
+ */
10
+
11
+ export interface KeyboardButton {
12
+ id: string;
13
+ render_data: { label: string; visited_label: string; style: number };
14
+ action: {
15
+ type: 1;
16
+ permission: { type: 2 };
17
+ data: string;
18
+ unsupport_tips: string;
19
+ };
20
+ }
21
+
22
+ export interface InlineKeyboard {
23
+ content: { rows: { buttons: KeyboardButton[] }[] };
24
+ }
25
+
26
+ /** 按钮 label 保守上限(官方未给硬限,对齐同平台 dsh-qqbot 的 10 字符) */
27
+ export const BUTTON_LABEL_MAX = 10;
28
+ /** 每行按钮数(2 列布局,减少选项多时的刷屏) */
29
+ export const BUTTONS_PER_ROW = 2;
30
+ /** 官方上限:5 行 */
31
+ export const BUTTON_ROW_MAX = 5;
32
+
33
+ /** 截断按钮 label,超长补省略号(正文保留完整文本) */
34
+ export function buttonLabel(text: string): string {
35
+ const s = String(text ?? "").trim();
36
+ return s.length > BUTTON_LABEL_MAX ? `${s.slice(0, BUTTON_LABEL_MAX - 1)}…` : s;
37
+ }
38
+
39
+ /** 审批按钮载荷:s = 审批编号(与 Approver.pending 的 seq 对应) */
40
+ export interface ApprovalButtonData {
41
+ t: "approval";
42
+ d: "allow" | "deny" | "always";
43
+ s: number;
44
+ }
45
+
46
+ /** 选择按钮载荷:i = 1 起始的选项编号(与「回复编号」口径一致) */
47
+ export interface PickButtonData {
48
+ t: "pick";
49
+ i: number;
50
+ }
51
+
52
+ export function encodeButtonData(data: ApprovalButtonData | PickButtonData): string {
53
+ return JSON.stringify(data);
54
+ }
55
+
56
+ /** 解码 button_data;非法 JSON 或缺少合法顶层判别字段时返回 undefined */
57
+ export function decodeButtonData(raw: unknown): ApprovalButtonData | PickButtonData | undefined {
58
+ if (typeof raw !== "string" || !raw) return undefined;
59
+ try {
60
+ const parsed = JSON.parse(raw) as Partial<ApprovalButtonData | PickButtonData> | null;
61
+ if (!parsed || typeof parsed !== "object") return undefined;
62
+ if (parsed.t === "approval" && (parsed.d === "allow" || parsed.d === "deny" || parsed.d === "always")) {
63
+ return parsed as ApprovalButtonData;
64
+ }
65
+ if (parsed.t === "pick" && typeof parsed.i === "number" && Number.isFinite(parsed.i)) {
66
+ return parsed as PickButtonData;
67
+ }
68
+ } catch {
69
+ // 非法 JSON → undefined
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ /**
75
+ * 审批键盘:允许一次 / 拒绝(非 strict 再加「以后都同意」),全部放在一行。
76
+ * 点击由服务端按 seq 匹配 pending:过期卡片点击不 settle(ACK 重复操作)。
77
+ */
78
+ export function buildApprovalKeyboard(seq: number, opts: { strict?: boolean } = {}): InlineKeyboard {
79
+ const allow: KeyboardButton = {
80
+ id: `approval-allow-${seq}`,
81
+ render_data: { label: "✅ 允许一次", visited_label: "✓ 已允许", style: 1 },
82
+ action: {
83
+ type: 1,
84
+ permission: { type: 2 },
85
+ data: encodeButtonData({ t: "approval", d: "allow", s: seq }),
86
+ unsupport_tips: "当前客户端不支持按钮,请回复「同意」",
87
+ },
88
+ };
89
+ const deny: KeyboardButton = {
90
+ id: `approval-deny-${seq}`,
91
+ render_data: { label: "❌ 拒绝", visited_label: "✓ 已拒绝", style: 0 },
92
+ action: {
93
+ type: 1,
94
+ permission: { type: 2 },
95
+ data: encodeButtonData({ t: "approval", d: "deny", s: seq }),
96
+ unsupport_tips: "当前客户端不支持按钮,请回复「拒绝」",
97
+ },
98
+ };
99
+ const buttons: KeyboardButton[] = [allow, deny];
100
+ if (!opts.strict) {
101
+ buttons.push({
102
+ id: `approval-always-${seq}`,
103
+ render_data: { label: "🚀 以后都同意", visited_label: "✓ 已放行", style: 1 },
104
+ action: {
105
+ type: 1,
106
+ permission: { type: 2 },
107
+ data: encodeButtonData({ t: "approval", d: "always", s: seq }),
108
+ unsupport_tips: "当前客户端不支持按钮,请回复「以后都同意」",
109
+ },
110
+ });
111
+ }
112
+ return { content: { rows: [{ buttons }] } };
113
+ }
114
+
115
+ /**
116
+ * 选择键盘:每行 2 个按钮、最多 5 行;超出部分不生成按钮(正文编号仍可用)。
117
+ * i 从 1 起与「回复编号」一致。
118
+ */
119
+ export function buildPickerKeyboard(items: { label: string }[]): InlineKeyboard | undefined {
120
+ if (items.length === 0) return undefined;
121
+ const rows: { buttons: KeyboardButton[] }[] = [];
122
+ const cap = BUTTON_ROW_MAX * BUTTONS_PER_ROW;
123
+ for (let i = 0; i < items.length && i < cap; i += BUTTONS_PER_ROW) {
124
+ rows.push({
125
+ buttons: items.slice(i, i + BUTTONS_PER_ROW).map((item, offset) => {
126
+ const idx = i + offset;
127
+ return {
128
+ id: `pick-${idx}`,
129
+ render_data: { label: buttonLabel(item.label), visited_label: "✓ 已选", style: 1 },
130
+ action: {
131
+ type: 1,
132
+ permission: { type: 2 },
133
+ data: encodeButtonData({ t: "pick", i: idx + 1 }),
134
+ unsupport_tips: "当前客户端不支持按钮,请回复编号选择",
135
+ },
136
+ };
137
+ }),
138
+ });
139
+ }
140
+ return { content: { rows } };
141
+ }