@zxh126/pi-qqbot 2.4.1 → 2.6.2

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/queue.ts CHANGED
@@ -1,120 +1,120 @@
1
- import { redactSecrets } from "./redact";
2
-
3
- /**
4
- * QQ 侧任务队列。
5
- *
6
- * pi 的单会话是串行执行的:一轮 agent run 结束(agent_settled)后才可能开始下一轮。
7
- * 于是忙碌期间收到的消息不再丢弃/拒绝,而是排队;当前任务结束后由 index.ts 的
8
- * `drainQueue()` 逐条取出执行。
9
- *
10
- * 设计要点:
11
- * - 全局单队列(本扩展是单会话设计),每条任务记住自己的 openid,回复发给原主人。
12
- * - FIFO,编号单调递增不复用,方便用户用 `/queue drop <编号>` 定点删除。
13
- * - `/cancel` 不走队列(立即生效),并且会清空队列。
14
- */
15
-
16
- export interface QueuedJob {
17
- /** 队列编号,从 1 开始单调递增 */
18
- id: number;
19
- /** 任务来自哪个 QQ 用户 */
20
- openid: string;
21
- /** 交给 pi 的原始文本(普通对话 / 斜杠命令 / 提示模板 / skill 命令) */
22
- text: string;
23
- /** 入队时间戳(毫秒) */
24
- enqueuedAt: number;
25
- /** 派发失败的重试次数,由 index.ts 维护 */
26
- attempts: number;
27
- }
28
-
29
- export type PushResult =
30
- | { ok: true; job: QueuedJob; ahead: number }
31
- | { ok: false; reason: "full" };
32
-
33
- export const DEFAULT_QUEUE_MAX = 20;
34
- /** 单条预览的最大字符数 */
35
- const PREVIEW_MAX = 48;
36
-
37
- export function previewText(text: string, max = PREVIEW_MAX): string {
38
- // 预览会回显到 QQ 和日志:先脱敏
39
- const flat = redactSecrets(text).replace(/\s+/g, " ").trim();
40
- return flat.length > max ? `${flat.slice(0, max)}…` : flat || "(空消息)";
41
- }
42
-
43
- /** 距离入队多久:`12s 前` / `3m 前` / `1h 前` */
44
- export function agoLabel(at: number, now = Date.now()): string {
45
- const seconds = Math.max(0, Math.round((now - at) / 1000));
46
- if (seconds < 60) return `${seconds}s 前`;
47
- const minutes = Math.floor(seconds / 60);
48
- if (minutes < 60) return `${minutes}m 前`;
49
- return `${Math.floor(minutes / 60)}h 前`;
50
- }
51
-
52
- export class MessageQueue {
53
- private jobs: QueuedJob[] = [];
54
- private nextId = 1;
55
- private limit: number;
56
-
57
- /** `limit = 0` 表示关闭排队(忙碌时直接拒绝新消息) */
58
- constructor(limit = DEFAULT_QUEUE_MAX) {
59
- this.limit = Math.max(0, Math.floor(limit) || 0);
60
- }
61
-
62
- /** 按配置调整上限;已有的任务不会被丢弃 */
63
- setLimit(limit: number) {
64
- this.limit = Math.max(0, Math.floor(limit) || 0);
65
- }
66
-
67
- get size(): number {
68
- return this.jobs.length;
69
- }
70
-
71
- get max(): number {
72
- return this.limit;
73
- }
74
-
75
- /** 只读快照(保持插入顺序) */
76
- list(): readonly QueuedJob[] {
77
- return this.jobs;
78
- }
79
-
80
- push(openid: string, text: string): PushResult {
81
- if (this.limit === 0 || this.jobs.length >= this.limit) return { ok: false, reason: "full" };
82
- const job: QueuedJob = {
83
- id: this.nextId++,
84
- openid,
85
- text,
86
- enqueuedAt: Date.now(),
87
- attempts: 0,
88
- };
89
- this.jobs.push(job);
90
- return { ok: true, job, ahead: this.jobs.length - 1 };
91
- }
92
-
93
- /** 手动构造一条不入队的任务(直接执行 / 失败重试时用) */
94
- mint(openid: string, text: string): QueuedJob {
95
- return { id: 0, openid, text, enqueuedAt: Date.now(), attempts: 0 };
96
- }
97
-
98
- shift(): QueuedJob | undefined {
99
- return this.jobs.shift();
100
- }
101
-
102
- /** 派发失败时放回队首重试 */
103
- unshift(job: QueuedJob): void {
104
- this.jobs.unshift(job);
105
- }
106
-
107
- /** 按编号删除一条 */
108
- remove(id: number): QueuedJob | undefined {
109
- const index = this.jobs.findIndex((job) => job.id === id);
110
- if (index === -1) return undefined;
111
- return this.jobs.splice(index, 1)[0];
112
- }
113
-
114
- /** 清空并返回被清掉的任务 */
115
- clear(): QueuedJob[] {
116
- const dropped = this.jobs;
117
- this.jobs = [];
118
- return dropped;
119
- }
120
- }
1
+ import { redactSecrets } from "./redact";
2
+
3
+ /**
4
+ * QQ 侧任务队列。
5
+ *
6
+ * pi 的单会话是串行执行的:一轮 agent run 结束(agent_settled)后才可能开始下一轮。
7
+ * 于是忙碌期间收到的消息不再丢弃/拒绝,而是排队;当前任务结束后由 index.ts 的
8
+ * `drainQueue()` 逐条取出执行。
9
+ *
10
+ * 设计要点:
11
+ * - 全局单队列(本扩展是单会话设计),每条任务记住自己的 openid,回复发给原主人。
12
+ * - FIFO,编号单调递增不复用,方便用户用 `/queue drop <编号>` 定点删除。
13
+ * - `/cancel` 不走队列(立即生效),并且会清空队列。
14
+ */
15
+
16
+ export interface QueuedJob {
17
+ /** 队列编号,从 1 开始单调递增 */
18
+ id: number;
19
+ /** 任务来自哪个 QQ 用户 */
20
+ openid: string;
21
+ /** 交给 pi 的原始文本(普通对话 / 斜杠命令 / 提示模板 / skill 命令) */
22
+ text: string;
23
+ /** 入队时间戳(毫秒) */
24
+ enqueuedAt: number;
25
+ /** 派发失败的重试次数,由 index.ts 维护 */
26
+ attempts: number;
27
+ }
28
+
29
+ export type PushResult =
30
+ | { ok: true; job: QueuedJob; ahead: number }
31
+ | { ok: false; reason: "full" };
32
+
33
+ export const DEFAULT_QUEUE_MAX = 20;
34
+ /** 单条预览的最大字符数 */
35
+ const PREVIEW_MAX = 48;
36
+
37
+ export function previewText(text: string, max = PREVIEW_MAX): string {
38
+ // 预览会回显到 QQ 和日志:先脱敏
39
+ const flat = redactSecrets(text).replace(/\s+/g, " ").trim();
40
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat || "(空消息)";
41
+ }
42
+
43
+ /** 距离入队多久:`12s 前` / `3m 前` / `1h 前` */
44
+ export function agoLabel(at: number, now = Date.now()): string {
45
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
46
+ if (seconds < 60) return `${seconds}s 前`;
47
+ const minutes = Math.floor(seconds / 60);
48
+ if (minutes < 60) return `${minutes}m 前`;
49
+ return `${Math.floor(minutes / 60)}h 前`;
50
+ }
51
+
52
+ export class MessageQueue {
53
+ private jobs: QueuedJob[] = [];
54
+ private nextId = 1;
55
+ private limit: number;
56
+
57
+ /** `limit = 0` 表示关闭排队(忙碌时直接拒绝新消息) */
58
+ constructor(limit = DEFAULT_QUEUE_MAX) {
59
+ this.limit = Math.max(0, Math.floor(limit) || 0);
60
+ }
61
+
62
+ /** 按配置调整上限;已有的任务不会被丢弃 */
63
+ setLimit(limit: number) {
64
+ this.limit = Math.max(0, Math.floor(limit) || 0);
65
+ }
66
+
67
+ get size(): number {
68
+ return this.jobs.length;
69
+ }
70
+
71
+ get max(): number {
72
+ return this.limit;
73
+ }
74
+
75
+ /** 只读快照(保持插入顺序) */
76
+ list(): readonly QueuedJob[] {
77
+ return this.jobs;
78
+ }
79
+
80
+ push(openid: string, text: string): PushResult {
81
+ if (this.limit === 0 || this.jobs.length >= this.limit) return { ok: false, reason: "full" };
82
+ const job: QueuedJob = {
83
+ id: this.nextId++,
84
+ openid,
85
+ text,
86
+ enqueuedAt: Date.now(),
87
+ attempts: 0,
88
+ };
89
+ this.jobs.push(job);
90
+ return { ok: true, job, ahead: this.jobs.length - 1 };
91
+ }
92
+
93
+ /** 手动构造一条不入队的任务(直接执行 / 失败重试时用) */
94
+ mint(openid: string, text: string): QueuedJob {
95
+ return { id: 0, openid, text, enqueuedAt: Date.now(), attempts: 0 };
96
+ }
97
+
98
+ shift(): QueuedJob | undefined {
99
+ return this.jobs.shift();
100
+ }
101
+
102
+ /** 派发失败时放回队首重试 */
103
+ unshift(job: QueuedJob): void {
104
+ this.jobs.unshift(job);
105
+ }
106
+
107
+ /** 按编号删除一条 */
108
+ remove(id: number): QueuedJob | undefined {
109
+ const index = this.jobs.findIndex((job) => job.id === id);
110
+ if (index === -1) return undefined;
111
+ return this.jobs.splice(index, 1)[0];
112
+ }
113
+
114
+ /** 清空并返回被清掉的任务 */
115
+ clear(): QueuedJob[] {
116
+ const dropped = this.jobs;
117
+ this.jobs = [];
118
+ return dropped;
119
+ }
120
+ }
package/redact.ts CHANGED
@@ -1,136 +1,136 @@
1
- /**
2
- * 敏感信息脱敏。
3
- *
4
- * 所有要「离开本地」或「落盘」的文本都应该先过这里:
5
- * - 发到 QQ 的消息(进度摘要、审批提示、队列预览)
6
- * - pi-qqbot.log
7
- *
8
- * 原则:
9
- * 1. 认得出的凭据一律替换成 `***`(保留键名,方便排错)
10
- * 2. 不碰正常内容(路径、命令、中文句子、git sha、UUID)
11
- * 3. 幂等:对已脱敏文本再跑一次不会再变形
12
- */
13
-
14
- /** PEM 私钥 / 证书块:整块替换 */
15
- const PEM_BLOCK =
16
- /-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CERTIFICATE)-----[\s\S]*?-----END [A-Z0-9 ]*(?:PRIVATE KEY|CERTIFICATE)-----/g;
17
-
18
- /** `Authorization: Bearer xxx` / `QQBot xxx` / `Bot xxx` / `Token xxx` */
19
- const AUTH_HEADER =
20
- /\b(authorization\s*[:=]\s*)(?:(?:bearer|basic|token|qqbot|bot|digest)\s+)?[^\s"',;]{6,}/gi;
21
-
22
- /** URL 里的 user:password@host */
23
- const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi;
24
-
25
- /** 敏感的键名(键值对 / JSON / 查询参数 / --flag) */
26
- const SENSITIVE_KEYS = [
27
- 'access[_-]?token',
28
- 'refresh[_-]?token',
29
- 'id[_-]?token',
30
- 'auth[_-]?token',
31
- 'api[_-]?key',
32
- 'apikey',
33
- 'x-api-key',
34
- 'client[_-]?id',
35
- 'client[_-]?secret',
36
- 'app[_-]?secret',
37
- 'appsecret',
38
- 'secret[_-]?key',
39
- 'private[_-]?key',
40
- 'session[_-]?id',
41
- 'sessionid',
42
- 'password',
43
- 'passwd',
44
- 'pwd',
45
- 'passphrase',
46
- 'credentials?',
47
- 'authorization',
48
- 'cookie',
49
- 'set-cookie',
50
- 'signature',
51
- 'sign',
52
- 'nonce',
53
- 'ticket',
54
- 'secret',
55
- ].join('|');
56
-
57
- /** `key=value` / `"key": "value"` / `key: value`(值两侧的引号一并吃掉) */
58
- const KEY_VALUE = new RegExp(
59
- `((?<![A-Za-z0-9_])(?:${SENSITIVE_KEYS})(?:["']?\\s*[:=]\\s*["']?))(?!\\*\\*\\*)[^\\s"',;&]{3,}`,
60
- 'gi',
61
- );
62
-
63
- /** 命令行形式:`--token xxx` / `--api-key xxx`(只认带短横线的 flag,避免误伤普通词) */
64
- const FLAG_VALUE = new RegExp(
65
- `((?:^|\\s)--?(?:access-token|api-key|apikey|token|auth-token|client-secret|secret|password|passwd|pwd)\\s+["']?)(?!\\*\\*\\*)[^\\s"',;&]{3,}`,
66
- 'gi',
67
- );
68
-
69
- /** 中文键名 */
70
- const CN_KEY_VALUE = /((?:密码|密钥|秘钥|令牌|凭证|口令)\s*[::=]\s*)(?!\*{3})\S+/g;
71
-
72
- /** 已知凭据格式 */
73
- const TOKEN_SHAPES: Array<[RegExp, string]> = [
74
- [/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{12,}/g, 'sk-***'],
75
- [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, 'github_pat_***'],
76
- [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, 'gh*_***'],
77
- [/\bnpm_[A-Za-z0-9]{30,}/g, 'npm_***'],
78
- [/\bpypi-[A-Za-z0-9_-]{20,}/g, 'pypi-***'],
79
- [/\bglpat-[A-Za-z0-9_-]{16,}/g, 'glpat-***'],
80
- [/\bhf_[A-Za-z0-9]{20,}/g, 'hf_***'],
81
- [/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***'],
82
- [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, 'xox*-***'],
83
- [/\bAIza[0-9A-Za-z_-]{30,}/g, 'AIza***'],
84
- [/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g, 'sk_live_***'],
85
- [/\bSG\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'SG.***'],
86
- [/\bROBOT1\.0_[A-Za-z0-9._!-]{10,}/g, 'ROBOT1.0_***'],
87
- [/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, 'eyJ*** (JWT)'],
88
- ];
89
-
90
- /**
91
- * 兜底:超长高熵串(≥44 字符且同时含大小写与数字)。
92
- * 刻意放过纯小写十六进制(git sha / md5)与带连字符的 UUID,避免把正常内容打码。
93
- */
94
- const HIGH_ENTROPY = /\b(?=[A-Za-z0-9+/=_-]{44,}\b)(?=[^\s]*[a-z])(?=[^\s]*[A-Z])(?=[^\s]*\d)[A-Za-z0-9+/=_-]{44,}\b/g;
95
-
96
- export function redactSecrets(input: string): string {
97
- if (!input) return input;
98
- let out = input;
99
- out = out.replace(PEM_BLOCK, '<已隐藏的私钥/证书>');
100
- out = out.replace(AUTH_HEADER, '$1***');
101
- out = out.replace(URL_USERINFO, '$1***:***@');
102
- out = out.replace(KEY_VALUE, '$1***');
103
- out = out.replace(FLAG_VALUE, '$1***');
104
- out = out.replace(CN_KEY_VALUE, '$1***');
105
- for (const [pattern, replacement] of TOKEN_SHAPES) out = out.replace(pattern, replacement);
106
- out = out.replace(HIGH_ENTROPY, '***');
107
- return out;
108
- }
109
-
110
- /** 递归脱敏:字符串值处理,数组/对象继续下钻,其余原样 */
111
- export function redactDeep<T>(value: T, depth = 0): T {
112
- if (depth > 6 || value == null) return value;
113
- if (typeof value === 'string') return redactSecrets(value) as unknown as T;
114
- if (Array.isArray(value)) return value.map((item) => redactDeep(item, depth + 1)) as unknown as T;
115
- if (typeof value === 'object') {
116
- const out: Record<string, unknown> = {};
117
- for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
118
- out[key] = /pass|secret|token|key|credential|cookie|auth|sig/i.test(key)
119
- ? typeof item === 'string'
120
- ? '***'
121
- : '***'
122
- : redactDeep(item, depth + 1);
123
- }
124
- return out as unknown as T;
125
- }
126
- return value;
127
- }
128
-
129
- /** 单行化 + 脱敏 + 截断(用于 QQ 消息里的参数摘要) */
130
- export function shortRedacted(value: unknown, limit = 60): string {
131
- const flat = String(value ?? '')
132
- .replace(/\s+/g, ' ')
133
- .trim();
134
- const safe = redactSecrets(flat);
135
- return safe.length > limit ? `${safe.slice(0, limit)}…` : safe;
136
- }
1
+ /**
2
+ * 敏感信息脱敏。
3
+ *
4
+ * 所有要「离开本地」或「落盘」的文本都应该先过这里:
5
+ * - 发到 QQ 的消息(进度摘要、审批提示、队列预览)
6
+ * - pi-qqbot.log
7
+ *
8
+ * 原则:
9
+ * 1. 认得出的凭据一律替换成 `***`(保留键名,方便排错)
10
+ * 2. 不碰正常内容(路径、命令、中文句子、git sha、UUID)
11
+ * 3. 幂等:对已脱敏文本再跑一次不会再变形
12
+ */
13
+
14
+ /** PEM 私钥 / 证书块:整块替换 */
15
+ const PEM_BLOCK =
16
+ /-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CERTIFICATE)-----[\s\S]*?-----END [A-Z0-9 ]*(?:PRIVATE KEY|CERTIFICATE)-----/g;
17
+
18
+ /** `Authorization: Bearer xxx` / `QQBot xxx` / `Bot xxx` / `Token xxx` */
19
+ const AUTH_HEADER =
20
+ /\b(authorization\s*[:=]\s*)(?:(?:bearer|basic|token|qqbot|bot|digest)\s+)?[^\s"',;]{6,}/gi;
21
+
22
+ /** URL 里的 user:password@host */
23
+ const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi;
24
+
25
+ /** 敏感的键名(键值对 / JSON / 查询参数 / --flag) */
26
+ const SENSITIVE_KEYS = [
27
+ 'access[_-]?token',
28
+ 'refresh[_-]?token',
29
+ 'id[_-]?token',
30
+ 'auth[_-]?token',
31
+ 'api[_-]?key',
32
+ 'apikey',
33
+ 'x-api-key',
34
+ 'client[_-]?id',
35
+ 'client[_-]?secret',
36
+ 'app[_-]?secret',
37
+ 'appsecret',
38
+ 'secret[_-]?key',
39
+ 'private[_-]?key',
40
+ 'session[_-]?id',
41
+ 'sessionid',
42
+ 'password',
43
+ 'passwd',
44
+ 'pwd',
45
+ 'passphrase',
46
+ 'credentials?',
47
+ 'authorization',
48
+ 'cookie',
49
+ 'set-cookie',
50
+ 'signature',
51
+ 'sign',
52
+ 'nonce',
53
+ 'ticket',
54
+ 'secret',
55
+ ].join('|');
56
+
57
+ /** `key=value` / `"key": "value"` / `key: value`(值两侧的引号一并吃掉) */
58
+ const KEY_VALUE = new RegExp(
59
+ `((?<![A-Za-z0-9_])(?:${SENSITIVE_KEYS})(?:["']?\\s*[:=]\\s*["']?))(?!\\*\\*\\*)[^\\s"',;&]{3,}`,
60
+ 'gi',
61
+ );
62
+
63
+ /** 命令行形式:`--token xxx` / `--api-key xxx`(只认带短横线的 flag,避免误伤普通词) */
64
+ const FLAG_VALUE = new RegExp(
65
+ `((?:^|\\s)--?(?:access-token|api-key|apikey|token|auth-token|client-secret|secret|password|passwd|pwd)\\s+["']?)(?!\\*\\*\\*)[^\\s"',;&]{3,}`,
66
+ 'gi',
67
+ );
68
+
69
+ /** 中文键名 */
70
+ const CN_KEY_VALUE = /((?:密码|密钥|秘钥|令牌|凭证|口令)\s*[::=]\s*)(?!\*{3})\S+/g;
71
+
72
+ /** 已知凭据格式 */
73
+ const TOKEN_SHAPES: Array<[RegExp, string]> = [
74
+ [/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{12,}/g, 'sk-***'],
75
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, 'github_pat_***'],
76
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, 'gh*_***'],
77
+ [/\bnpm_[A-Za-z0-9]{30,}/g, 'npm_***'],
78
+ [/\bpypi-[A-Za-z0-9_-]{20,}/g, 'pypi-***'],
79
+ [/\bglpat-[A-Za-z0-9_-]{16,}/g, 'glpat-***'],
80
+ [/\bhf_[A-Za-z0-9]{20,}/g, 'hf_***'],
81
+ [/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***'],
82
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, 'xox*-***'],
83
+ [/\bAIza[0-9A-Za-z_-]{30,}/g, 'AIza***'],
84
+ [/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g, 'sk_live_***'],
85
+ [/\bSG\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, 'SG.***'],
86
+ [/\bROBOT1\.0_[A-Za-z0-9._!-]{10,}/g, 'ROBOT1.0_***'],
87
+ [/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g, 'eyJ*** (JWT)'],
88
+ ];
89
+
90
+ /**
91
+ * 兜底:超长高熵串(≥44 字符且同时含大小写与数字)。
92
+ * 刻意放过纯小写十六进制(git sha / md5)与带连字符的 UUID,避免把正常内容打码。
93
+ */
94
+ const HIGH_ENTROPY = /\b(?=[A-Za-z0-9+/=_-]{44,}\b)(?=[^\s]*[a-z])(?=[^\s]*[A-Z])(?=[^\s]*\d)[A-Za-z0-9+/=_-]{44,}\b/g;
95
+
96
+ export function redactSecrets(input: string): string {
97
+ if (!input) return input;
98
+ let out = input;
99
+ out = out.replace(PEM_BLOCK, '<已隐藏的私钥/证书>');
100
+ out = out.replace(AUTH_HEADER, '$1***');
101
+ out = out.replace(URL_USERINFO, '$1***:***@');
102
+ out = out.replace(KEY_VALUE, '$1***');
103
+ out = out.replace(FLAG_VALUE, '$1***');
104
+ out = out.replace(CN_KEY_VALUE, '$1***');
105
+ for (const [pattern, replacement] of TOKEN_SHAPES) out = out.replace(pattern, replacement);
106
+ out = out.replace(HIGH_ENTROPY, '***');
107
+ return out;
108
+ }
109
+
110
+ /** 递归脱敏:字符串值处理,数组/对象继续下钻,其余原样 */
111
+ export function redactDeep<T>(value: T, depth = 0): T {
112
+ if (depth > 6 || value == null) return value;
113
+ if (typeof value === 'string') return redactSecrets(value) as unknown as T;
114
+ if (Array.isArray(value)) return value.map((item) => redactDeep(item, depth + 1)) as unknown as T;
115
+ if (typeof value === 'object') {
116
+ const out: Record<string, unknown> = {};
117
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
118
+ out[key] = /pass|secret|token|key|credential|cookie|auth|sig/i.test(key)
119
+ ? typeof item === 'string'
120
+ ? '***'
121
+ : '***'
122
+ : redactDeep(item, depth + 1);
123
+ }
124
+ return out as unknown as T;
125
+ }
126
+ return value;
127
+ }
128
+
129
+ /** 单行化 + 脱敏 + 截断(用于 QQ 消息里的参数摘要) */
130
+ export function shortRedacted(value: unknown, limit = 60): string {
131
+ const flat = String(value ?? '')
132
+ .replace(/\s+/g, ' ')
133
+ .trim();
134
+ const safe = redactSecrets(flat);
135
+ return safe.length > limit ? `${safe.slice(0, limit)}…` : safe;
136
+ }