@qzsy/dsh-bridge 2.18.0 → 3.0.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/CHANGELOG.md +12 -1098
- package/LICENSE +21 -21
- package/README.md +72 -476
- package/client/build.mjs +1 -3
- package/client/client.js +381 -9747
- package/client/index.js +242 -6828
- package/client/locales.mjs +99 -233
- package/cordis.patch.yml +5 -3
- package/lib/config.js +93 -0
- package/lib/data-dir.js +16 -0
- package/lib/dsh-cookie.js +111 -0
- package/lib/gate.js +382 -0
- package/lib/identity.js +23 -0
- package/lib/index.js +302 -3496
- package/lib/saas/tunnel-client.mjs +529 -467
- package/locale/en.json +3 -3
- package/locale/zh.json +3 -3
- package/package.json +15 -70
- package/README.en.md +0 -488
- package/client/host-families.js +0 -442
- package/client/mobile-styles.js +0 -1299
- package/client/mobile-yield.js +0 -219
- package/client/panel-locales.mjs +0 -777
- package/client/picker-yield.js +0 -77
- package/client/resource-url-compat.js +0 -90
- package/client/settings-nav-icon.js +0 -105
- package/client/theme-tokens.js +0 -49
- package/client/tunnel-guard.js +0 -46
- package/client/unlock-manager.js +0 -144
- package/lib/auth/dsh-native-cookie.js +0 -148
- package/lib/auth/login-template.js +0 -516
- package/lib/auth/manager.js +0 -596
- package/lib/bridge-rpc-constants.js +0 -66
- package/lib/bridge-rpc.js +0 -722
- package/lib/cloudflared-manager.mjs +0 -1277
- package/lib/cloudflared-network-args.js +0 -99
- package/lib/cloudflared-process-scan.js +0 -153
- package/lib/compat.js +0 -172
- package/lib/connection-compat.js +0 -115
- package/lib/feishu/comment.js +0 -50
- package/lib/feishu/gateway.js +0 -644
- package/lib/feishu/index.js +0 -432
- package/lib/feishu/lark-bundled.mjs +0 -131929
- package/lib/feishu/markdown.js +0 -148
- package/lib/feishu/node.js +0 -1152
- package/lib/feishu/slash-commands.js +0 -255
- package/lib/platform/account-ops.js +0 -248
- package/lib/platform/activity.js +0 -169
- package/lib/platform/ask-user.js +0 -293
- package/lib/platform/base.js +0 -147
- package/lib/platform/changes-digest.js +0 -93
- package/lib/platform/commands.js +0 -267
- package/lib/platform/config-restore.js +0 -78
- package/lib/platform/conversation-bridge.js +0 -1544
- package/lib/platform/dsh-storage.js +0 -222
- package/lib/platform/index.js +0 -10
- package/lib/platform/instances.js +0 -458
- package/lib/platform/manager.js +0 -130
- package/lib/platform/message-split.js +0 -229
- package/lib/platform/session-catalog.js +0 -405
- package/lib/platform/session-config.js +0 -52
- package/lib/platform/stream-slices.js +0 -21
- package/lib/qq/gateway.js +0 -1107
- package/lib/qq/index.js +0 -561
- package/lib/qq/node.js +0 -1153
- package/lib/qq/qqbot-bundled.mjs +0 -8273
- package/lib/restart-helper.mjs +0 -133
- package/lib/security/log-sanitize.js +0 -50
- package/lib/security/path-validator.js +0 -195
- package/lib/security/rate-limiter.js +0 -93
- package/lib/session-strip.js +0 -100
- package/lib/sse-keepalive.js +0 -115
- package/lib/telegram/gateway.js +0 -643
- package/lib/telegram/index.js +0 -255
- package/lib/telegram/node.js +0 -410
- package/lib/tunnel-client.mjs +0 -537
- package/lib/wechat/gateway.js +0 -1000
- package/lib/wechat/index.js +0 -282
- package/lib/wechat/media.js +0 -302
- package/lib/wechat/node.js +0 -360
- package/llms.txt +0 -36
package/lib/qq/node.js
DELETED
|
@@ -1,1153 +0,0 @@
|
|
|
1
|
-
// dsh-bridge QQ conversation node
|
|
2
|
-
//
|
|
3
|
-
// 把官方 SDK 归一化后的入站消息(C2C 私聊 / 群聊 / 频道 / 频道私信 / 互动事件)
|
|
4
|
-
// 交给平台无关的 ConversationBridge 处理,出站通过 QqGateway(官方 SDK)发送。
|
|
5
|
-
//
|
|
6
|
-
// 平台特定部分:
|
|
7
|
-
// - 入站:SDK 已给出 kind/senderId/groupOpenid/attachments/mentions/replyTarget,
|
|
8
|
-
// 这里只做业务映射(授权主体、被动回复候选、引用上下文、附件落盘)
|
|
9
|
-
// - 出站:单聊走官方 stream_messages 流式(replace 语义,一条消息逐渐变长);
|
|
10
|
-
// 群聊平台不支持流式,直接发 Markdown
|
|
11
|
-
// - 交互:按钮回调 → 审批决议 / 问答选项(微信/飞书同一套归属校验)
|
|
12
|
-
//
|
|
13
|
-
// 与旧自研实现相比,这里不再解析原始网关事件、不再自管心跳/重连/去重/分片,
|
|
14
|
-
// 全部由 @tencent-connect/qqbot-nodejs 承担。
|
|
15
|
-
|
|
16
|
-
import fs from 'node:fs'
|
|
17
|
-
import path from 'node:path'
|
|
18
|
-
import { randomUUID } from 'node:crypto'
|
|
19
|
-
import os from 'node:os'
|
|
20
|
-
import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* INTERACTION_CREATE 的应答码(官方 msg-btn.md 定义)。
|
|
24
|
-
*
|
|
25
|
-
* 这个值会直接回显给用户:平台按 code 弹不同 toast,
|
|
26
|
-
* 所以「未匹配/已过期」**不能**用 REPEAT(3)——那会让用户看到
|
|
27
|
-
* 「请勿重复操作」这种与事实不符的提示(实测踩过)。
|
|
28
|
-
*/
|
|
29
|
-
const INTERACTION_ACK = {
|
|
30
|
-
SUCCESS: 0, // 成功
|
|
31
|
-
FAILED: 1, // 操作失败(本插件无对应待处理项 / 已过期)
|
|
32
|
-
RATE_LIMITED: 2, // 操作频繁(未使用,留给限流场景)
|
|
33
|
-
REPEAT: 3, // 重复操作(同一 interaction 重复应答时平台才有此语义)
|
|
34
|
-
NO_PERMISSION: 4, // 没有权限(非发起者点击)
|
|
35
|
-
ADMIN_ONLY: 5, // 仅管理员操作(未使用)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** 活动状态行的 Markdown 样式(引用块 + 斜体,与飞书/Telegram 同一呈现) */
|
|
39
|
-
const STATUS_PREFIX = '> _'
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 把文本转成 QQ 安全 Markdown:
|
|
43
|
-
* - 标题降级:QQ 仅标准支持 # 与 ##,超出(###、####)降级为 ##
|
|
44
|
-
* - 列表/表格前空行:QQ 规定列表前若为普通文本必须有空行隔开,自动补齐空行
|
|
45
|
-
* - markdown 图片语法  转为链接 [alt](url)(避免图片转存失败导致整条失败)
|
|
46
|
-
* - 代码块内容原样保留
|
|
47
|
-
*/
|
|
48
|
-
export function sanitizeQQMarkdown(text) {
|
|
49
|
-
if (!text) return ''
|
|
50
|
-
const lines = String(text).split('\n')
|
|
51
|
-
const out = []
|
|
52
|
-
let inFence = false
|
|
53
|
-
const isListOrTable = (l) => /^\s*(-|\*|\d+\.|\/|\|)\s+/.test(l) || /^\s*\|.*\|\s*$/.test(l)
|
|
54
|
-
const isHeading = (l) => /^\s*#{1,6}\s+/.test(l)
|
|
55
|
-
|
|
56
|
-
for (let i = 0; i < lines.length; i++) {
|
|
57
|
-
let line = lines[i]
|
|
58
|
-
if (/^\s*```/.test(line)) {
|
|
59
|
-
inFence = !inFence
|
|
60
|
-
out.push(line)
|
|
61
|
-
continue
|
|
62
|
-
}
|
|
63
|
-
if (inFence) { out.push(line); continue }
|
|
64
|
-
|
|
65
|
-
// 标题级别规范化:QQ 标准支持 # 和 ##,超出(###+)统一转为 ##
|
|
66
|
-
if (isHeading(line)) {
|
|
67
|
-
line = line.replace(/^\s*#{3,}\s+/, '## ')
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 图片语法转为链接
|
|
71
|
-
line = line.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[$1]($2)')
|
|
72
|
-
|
|
73
|
-
// 列表/表格前空行保障:若前一行是非空普通文本且不是标题/列表/引用/表格,则插入空行
|
|
74
|
-
if (isListOrTable(line) && out.length > 0) {
|
|
75
|
-
const prev = out[out.length - 1]
|
|
76
|
-
if (prev && prev.trim() && !isListOrTable(prev) && !isHeading(prev) && !prev.startsWith('>')) {
|
|
77
|
-
out.push('')
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
out.push(line)
|
|
82
|
-
}
|
|
83
|
-
return out.join('\n')
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** 允许落盘的附件扩展名(QQ 附件只可能是这些类型;其余一律 .bin) */
|
|
87
|
-
const ALLOWED_ATTACHMENT_EXTS = new Set([
|
|
88
|
-
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp',
|
|
89
|
-
'.mp4', '.mov', '.webm', '.avi',
|
|
90
|
-
'.silk', '.amr', '.wav', '.mp3', '.ogg',
|
|
91
|
-
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
92
|
-
'.txt', '.md', '.csv', '.json', '.zip', '.tar', '.gz', '.7z', '.rar',
|
|
93
|
-
])
|
|
94
|
-
|
|
95
|
-
/** 附件下载大小上限(防止远端塞超大文件把磁盘/内存打爆) */
|
|
96
|
-
const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024
|
|
97
|
-
|
|
98
|
-
/** 判断 IPv4 十进制整数 / 十六进制整数形式是否为私网(如 `2130706433` = 127.0.0.1)。 */
|
|
99
|
-
function isNumericPrivateHost(host) {
|
|
100
|
-
const asLong = (value, radix) => {
|
|
101
|
-
if (!/^[0-9a-fx]+$/i.test(value)) return null
|
|
102
|
-
const n = Number.parseInt(value, radix)
|
|
103
|
-
return Number.isSafeInteger(n) && n >= 0 && n <= 0xffffffff ? n : null
|
|
104
|
-
}
|
|
105
|
-
let value = null
|
|
106
|
-
if (/^\d+$/.test(host)) value = asLong(host, 10)
|
|
107
|
-
else if (/^0x[0-9a-f]+$/i.test(host)) value = asLong(host.slice(2), 16)
|
|
108
|
-
if (value === null) return false
|
|
109
|
-
const [a, b] = [(value >>> 24) & 0xff, (value >>> 16) & 0xff]
|
|
110
|
-
if (a === 0 || a === 10 || a === 127) return true
|
|
111
|
-
if (a === 169 && b === 254) return true
|
|
112
|
-
if (a === 172 && b >= 16 && b <= 31) return true
|
|
113
|
-
if (a === 192 && b === 168) return true
|
|
114
|
-
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64/10
|
|
115
|
-
return false
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** 公网域名白名单以外的域名,解析后必须校验 IP。
|
|
119
|
-
* QQ 的附件/图片域名固定在这几个后缀内;其余域名一律走解析后校验。
|
|
120
|
-
*/
|
|
121
|
-
const QQ_MEDIA_HOST_SUFFIXES = [
|
|
122
|
-
'qq.com', 'qq.com.cn', 'qpic.cn', 'gtimg.cn', 'tencent.com', 'myqcloud.com',
|
|
123
|
-
]
|
|
124
|
-
|
|
125
|
-
/** 域名是否属于腾讯 QQ 媒体域名(这些不做 DNS 校验,直接用)。 */
|
|
126
|
-
function isQQMediaHost(host) {
|
|
127
|
-
return QQ_MEDIA_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(`.${suffix}`))
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* 解析域名并校验所有解析结果都不是内网地址(防 DNS rebinding)。
|
|
132
|
-
*
|
|
133
|
-
* 只做一次 lookuop;返回 false 表示解析失败或命中内网,调用方应拒绝下载。
|
|
134
|
-
* @param {string} host
|
|
135
|
-
* @returns {Promise<boolean>} true = 允许下载
|
|
136
|
-
*/
|
|
137
|
-
async function isHostResolvableToPublic(host) {
|
|
138
|
-
// 腾讯自有媒体域名直接放行(不发额外 DNS 查询,减少延迟与失败面)
|
|
139
|
-
if (isQQMediaHost(host)) return true
|
|
140
|
-
// 已经是 IP 字面量(新增/变异形态):上面的字符串判定已经处理,这里再兜一次
|
|
141
|
-
if (/^[\d.]+$/.test(host) || host.includes(':')) return true
|
|
142
|
-
let addresses
|
|
143
|
-
try {
|
|
144
|
-
const { lookup } = await import('node:dns/promises')
|
|
145
|
-
addresses = await lookup(host, { all: true, verbatim: true })
|
|
146
|
-
} catch {
|
|
147
|
-
return false // 解析失败:保守拒绝
|
|
148
|
-
}
|
|
149
|
-
if (!Array.isArray(addresses) || addresses.length === 0) return false
|
|
150
|
-
for (const entry of addresses) {
|
|
151
|
-
const address = String(entry?.address ?? '')
|
|
152
|
-
if (!address) return false
|
|
153
|
-
// 解析结果回到字符串判定:IPv4 私网 / IPv6 不可访问段都拦
|
|
154
|
-
if (address.includes(':')) {
|
|
155
|
-
if (isBlockedIPv6(address)) return false
|
|
156
|
-
} else if (isPrivateIPv4(address)) {
|
|
157
|
-
return false
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return true
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** 点分 IPv4 是否属于私网/保留段。 */
|
|
164
|
-
function isPrivateIPv4(host) {
|
|
165
|
-
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
|
|
166
|
-
if (!v4) return false
|
|
167
|
-
const [a, b] = [Number(v4[1]), Number(v4[2])]
|
|
168
|
-
if (a === 0 || a === 10 || a === 127) return true
|
|
169
|
-
if (a === 169 && b === 254) return true // 链路本地(云元数据 169.254.169.254)
|
|
170
|
-
if (a === 172 && b >= 16 && b <= 31) return true
|
|
171
|
-
if (a === 192 && b === 168) return true
|
|
172
|
-
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT
|
|
173
|
-
if (a >= 224) return true // 组播 / 保留 / 广播
|
|
174
|
-
return false
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* IPv6 是否属于不可访问段(未指定/环回/链路本地/ULA/组播/NAT64/6to4/IPv4-mapped 私网)。
|
|
179
|
-
* @param {string} host 已去掉方括号、小写化的地址
|
|
180
|
-
*/
|
|
181
|
-
function isBlockedIPv6(host) {
|
|
182
|
-
// 规范化:压缩形式与全展开形式都归一(`::` / `0:0:...:0`)
|
|
183
|
-
if (host === '::' || /^0(:0){1,7}$/.test(host)) return true // 未指定地址 → 访问本机
|
|
184
|
-
if (host === '::1' || /^0(:0){6}:1$/.test(host)) return true // 环回
|
|
185
|
-
if (/^fe[89ab][0-9a-f]?:/.test(host)) return true // 链路本地 fe80::/10
|
|
186
|
-
if (/^f[cd][0-9a-f]{2}:/.test(host)) return true // ULA fc00::/7
|
|
187
|
-
if (/^ff[0-9a-f]{2}:/.test(host)) return true // 组播 ff00::/8
|
|
188
|
-
if (/^64:ff9b::/.test(host)) return true // NAT64(可映射到 IPv4 私网)
|
|
189
|
-
if (/^2002:/.test(host)) return true // 6to4(内嵌 IPv4)
|
|
190
|
-
if (/^2001:(0|db8):/.test(host)) return true // Teredo / 文档段
|
|
191
|
-
|
|
192
|
-
// IPv4-mapped:URL 解析器把 ::ffff:127.0.0.1 归一化成 ::ffff:7f00:1(十六进制两段)
|
|
193
|
-
const mappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(host)
|
|
194
|
-
if (mappedDotted) return isPrivateIPv4(mappedDotted[1])
|
|
195
|
-
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host)
|
|
196
|
-
if (mappedHex) {
|
|
197
|
-
const hi = Number.parseInt(mappedHex[1], 16)
|
|
198
|
-
const lo = Number.parseInt(mappedHex[2], 16)
|
|
199
|
-
return isPrivateIPv4(`${(hi >>> 8) & 0xff}.${hi & 0xff}.${(lo >>> 8) & 0xff}.${lo & 0xff}`)
|
|
200
|
-
}
|
|
201
|
-
// ::ffff:0:x(带额外 0 段的映射变体)
|
|
202
|
-
const mappedVariant = /^::ffff:0:([\da-f]{1,4}):([\da-f]{1,4})$/.exec(host)
|
|
203
|
-
if (mappedVariant) return true
|
|
204
|
-
return false
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* 是否是不允许下载的地址(私网/回环/保留段/非 HTTP 协议,防 SSRF)。
|
|
209
|
-
*
|
|
210
|
-
* 覆盖:IPv4 点分与整数/十六进制写法、IPv6 全部不可访问形态(`::`、`::1`、fe80::/10、
|
|
211
|
-
* fc00::/7、ff00::/8、NAT64 64:ff9b::/96、6to4 2002::/16、IPv4-mapped 及其变体)、
|
|
212
|
-
* `localhost` 及子域与末尾点变体、非 http(s) 协议。
|
|
213
|
-
*
|
|
214
|
-
* 本函数只做**字符串**判定。DNS 解析绕过(`127.0.0.1.nip.io` 这类公网域名指向内网)
|
|
215
|
-
* 由调用方在 fetch 前追加 `isHostResolvableToPublic()` 完成二次校验。
|
|
216
|
-
*/
|
|
217
|
-
function isBlockedDownloadUrl(rawUrl) {
|
|
218
|
-
let url
|
|
219
|
-
try { url = new URL(String(rawUrl)) } catch { return true }
|
|
220
|
-
if (url.protocol !== 'https:' && url.protocol !== 'http:') return true
|
|
221
|
-
// URL.hostname 对 IPv6 保留方括号;末尾点(`localhost.`)要归一化
|
|
222
|
-
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.+$/, '')
|
|
223
|
-
if (!host) return true
|
|
224
|
-
if (host === 'localhost' || host === '0.0.0.0') return true
|
|
225
|
-
if (host.endsWith('.localhost')) return true
|
|
226
|
-
|
|
227
|
-
if (host.includes(':')) return isBlockedIPv6(host)
|
|
228
|
-
if (isPrivateIPv4(host)) return true
|
|
229
|
-
if (isNumericPrivateHost(host)) return true
|
|
230
|
-
return false
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* 把「QQ 平台下发的附件」落到进程私有临时目录。
|
|
235
|
-
*
|
|
236
|
-
* 这是全插件**唯一**一处"把外部字节写盘"的地方,因此把全部防护集中在这里:
|
|
237
|
-
*
|
|
238
|
-
* 1. **来源可信性**:调用方已确认 URL 通过 `isBlockedDownloadUrl`(私网/非 http(s) 拒绝)
|
|
239
|
-
* 与 `isHostResolvableToPublic`(DNS 解析后 IP 校验,防 rebinding)。
|
|
240
|
-
* 2. **目标不可控**:目录由 `mkdtemp()` 随机生成(0700);文件名只保留 basename、
|
|
241
|
-
* 剔除控制字符/路径分隔符;这里再校验解析后仍在该目录内。
|
|
242
|
-
* 3. **不覆盖、不越权**:`flag: 'wx'` 独占创建 + `mode: 0o600`。
|
|
243
|
-
* 4. **内容校验**:写入前确认字节非空、不超上限,且扩展名在**允许清单**内
|
|
244
|
-
* (QQ 附件只会是这些类型),避免把任意内容落成任意后缀。
|
|
245
|
-
*
|
|
246
|
-
* 说明:CodeQL `js/http-to-file-access` 会标记"网络数据写文件"。本功能的目的就是
|
|
247
|
-
* 保存用户发来的附件,因此无法消除该模式;上述 4 层约束是明确的风险收敛措施。
|
|
248
|
-
*
|
|
249
|
-
* @param {string} mediaDir 进程私有临时目录(mkdtemp 创建)
|
|
250
|
-
* @param {string} safeName 已清洗的文件名
|
|
251
|
-
* @param {string} ext 扩展名(撞名重试时用)
|
|
252
|
-
* @param {Buffer} buf 文件内容
|
|
253
|
-
* @returns {Promise<string>} 实际写入路径
|
|
254
|
-
*/
|
|
255
|
-
async function writeAttachmentExclusive(mediaDir, safeName, ext, buf) {
|
|
256
|
-
// 内容校验:空文件与超限直接拒绝(正常下载路径已校验,这里兜底)
|
|
257
|
-
if (!Buffer.isBuffer(buf) || buf.length === 0) {
|
|
258
|
-
throw new Error('attachment body is empty')
|
|
259
|
-
}
|
|
260
|
-
if (buf.length > MAX_ATTACHMENT_BYTES) {
|
|
261
|
-
throw new Error(`attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes`)
|
|
262
|
-
}
|
|
263
|
-
const baseDir = path.resolve(mediaDir)
|
|
264
|
-
const attempt = (name) => {
|
|
265
|
-
// 扩展名允许清单:QQ 附件只会是图片/音视频/文档这几类;
|
|
266
|
-
// 不在清单内(或没有扩展名)统一落成 `.bin`,避免任意内容伪装成可执行后缀。
|
|
267
|
-
const rawExt = path.extname(name).toLowerCase()
|
|
268
|
-
const safeExt = ALLOWED_ATTACHMENT_EXTS.has(rawExt) ? rawExt : '.bin'
|
|
269
|
-
const stem = path.basename(name, path.extname(name))
|
|
270
|
-
const full = path.join(baseDir, `${stem}${safeExt}`)
|
|
271
|
-
// 归属校验:解析后必须仍在该目录内(防 .. / 绝对路径 / 符号链接逃逸)
|
|
272
|
-
if (path.dirname(path.resolve(full)) !== baseDir) {
|
|
273
|
-
throw new Error(`attachment path escapes media dir: ${name}`)
|
|
274
|
-
}
|
|
275
|
-
return full
|
|
276
|
-
}
|
|
277
|
-
const writeTo = async (target) => {
|
|
278
|
-
// 用独占创建拿到 fd,再经显式 Buffer 副本写入。
|
|
279
|
-
// 语义与 writeFile(buf, {flag:'wx'}) 相同(不覆盖、0600),但把"网络响应体"
|
|
280
|
-
// 与"文件写入"之间插入一层显式复制,且写入前已通过上面的内容/大小/后缀校验。
|
|
281
|
-
const handle = await fs.promises.open(target, 'wx', 0o600)
|
|
282
|
-
try {
|
|
283
|
-
// 显式复制一份写盘缓冲(脱离原始响应体引用)
|
|
284
|
-
const payload = Buffer.from(buf)
|
|
285
|
-
await handle.writeFile(payload)
|
|
286
|
-
} finally {
|
|
287
|
-
await handle.close()
|
|
288
|
-
}
|
|
289
|
-
return target
|
|
290
|
-
}
|
|
291
|
-
try {
|
|
292
|
-
return await writeTo(attempt(safeName))
|
|
293
|
-
} catch (writeErr) {
|
|
294
|
-
if (writeErr?.code !== 'EEXIST') throw writeErr
|
|
295
|
-
return writeTo(attempt(`qq_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`))
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* 按上限读取响应体:超过上限返回 null(并取消流),避免超大附件占满内存。
|
|
301
|
-
* @param {Response} res
|
|
302
|
-
* @param {number} limit
|
|
303
|
-
* @returns {Promise<Buffer|null>}
|
|
304
|
-
*/
|
|
305
|
-
async function readBodyWithLimit(res, limit) {
|
|
306
|
-
if (!res.body) {
|
|
307
|
-
const buf = Buffer.from(await res.arrayBuffer())
|
|
308
|
-
return buf.length > limit ? null : buf
|
|
309
|
-
}
|
|
310
|
-
const reader = res.body.getReader()
|
|
311
|
-
const chunks = []
|
|
312
|
-
let total = 0
|
|
313
|
-
try {
|
|
314
|
-
for (;;) {
|
|
315
|
-
const { done, value } = await reader.read()
|
|
316
|
-
if (done) break
|
|
317
|
-
total += value.byteLength
|
|
318
|
-
if (total > limit) {
|
|
319
|
-
try { await reader.cancel() } catch { /* 取消失败不致命 */ }
|
|
320
|
-
return null
|
|
321
|
-
}
|
|
322
|
-
chunks.push(Buffer.from(value))
|
|
323
|
-
}
|
|
324
|
-
} finally {
|
|
325
|
-
try { reader.releaseLock() } catch { /* 已释放 */ }
|
|
326
|
-
}
|
|
327
|
-
return Buffer.concat(chunks)
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* 把 QqGateway 适配为 ConversationBridge 需要的 Platform 消息接口。
|
|
332
|
-
*
|
|
333
|
-
* 能力钩子必须挂在 platform 上(桥调的是 this.platform.xxx,不是节点自身)——
|
|
334
|
-
* 飞书侧曾因挂错对象导致状态永不显示,这里遵循同一约定。
|
|
335
|
-
*/
|
|
336
|
-
function makePlatform(gateway, hooks = {}) {
|
|
337
|
-
return {
|
|
338
|
-
id: 'qq',
|
|
339
|
-
name: 'QQ',
|
|
340
|
-
get accountId() { return gateway?.accountId ?? '' },
|
|
341
|
-
get capabilities() { return gateway?.capabilities ?? {} },
|
|
342
|
-
// 真实 status:桥的离线守卫依赖它
|
|
343
|
-
get status() { return gateway?.status ?? 'idle' },
|
|
344
|
-
sendText: (peer, text, opts = {}) => gateway.sendText(peer, text, opts),
|
|
345
|
-
sendMarkdown: (peer, markdown, opts = {}) => gateway.sendMarkdown(peer, markdown, opts),
|
|
346
|
-
sendMediaFile: (peer, filePath, opts = {}) => gateway.sendMediaFile(peer, filePath, opts),
|
|
347
|
-
sendKeyboard: (peer, content, keyboard, opts = {}) => gateway.sendKeyboard(peer, content, keyboard, opts),
|
|
348
|
-
// 必须带 scope:桥在群聊里传的 peer 是 group openid,丢掉 scope 会被当成
|
|
349
|
-
// `c2c` 打到 /v2/users/{group_openid}/messages(平台必报错,且错误被降级成 debug)
|
|
350
|
-
sendTyping: (peer, state, opts = {}) => {
|
|
351
|
-
// 桥调用签名是 (peer, state),不带 scope;用最近入站 peer 的 scope 兜底
|
|
352
|
-
const scope = opts?.scope ?? (hooks.currentScope ? hooks.currentScope() : undefined) ?? 'c2c'
|
|
353
|
-
return gateway.sendTyping(peer, {
|
|
354
|
-
...(typeof state === 'number' ? { durationSeconds: 8 } : (state ?? {})),
|
|
355
|
-
scope,
|
|
356
|
-
})
|
|
357
|
-
},
|
|
358
|
-
...(typeof hooks.setActivityStatus === 'function'
|
|
359
|
-
? { setActivityStatus: (line, opts, parts) => hooks.setActivityStatus(line, opts, parts) }
|
|
360
|
-
: {}),
|
|
361
|
-
dispose() {},
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
export class QqConversationNode extends ConversationBridge {
|
|
366
|
-
/**
|
|
367
|
-
* @param {object} ctx Cordis 上下文
|
|
368
|
-
* @param {object} config 已持久化配置(allowFrom/间隔/活动会话等)
|
|
369
|
-
* @param {object} logger 日志器
|
|
370
|
-
* @param {object} [opts]
|
|
371
|
-
* @param {Function} [opts.onFirstSender]
|
|
372
|
-
* @param {Function} [opts.onActiveSessionChange]
|
|
373
|
-
* @param {object} [opts.gateway] 所属网关(多实例时不能依赖 ctx.qq 槽位)
|
|
374
|
-
* @param {string} [opts.instanceId]
|
|
375
|
-
*/
|
|
376
|
-
constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange, gateway: explicitGateway, instanceId } = {}) {
|
|
377
|
-
const gateway = explicitGateway ?? ctx.qq
|
|
378
|
-
super({
|
|
379
|
-
ctx,
|
|
380
|
-
logger,
|
|
381
|
-
config,
|
|
382
|
-
platform: makePlatform(gateway, {
|
|
383
|
-
// 惰性转发:makePlatform 在 super() 参数里构造,此刻 this 尚不可用
|
|
384
|
-
setActivityStatus: (line, opts, parts) => this.setActivityStatus(line, opts, parts),
|
|
385
|
-
// typing 需要正确的 scope(群聊不能打 users 端点):桥调 platform.sendTyping 时不带 scope,
|
|
386
|
-
// 这里回读最近入站 peer 的 scope 兜底
|
|
387
|
-
currentScope: () => this._lastPeer?.scope ?? 'c2c',
|
|
388
|
-
}),
|
|
389
|
-
onFirstSender,
|
|
390
|
-
onActiveSessionChange,
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
this.gateway = gateway
|
|
394
|
-
// 事件过滤标识:只处理本实例网关发出的事件,避免同类型多账号串消息
|
|
395
|
-
this.instanceId = instanceId ?? gateway?.instanceId ?? 'qq'
|
|
396
|
-
|
|
397
|
-
// 进程级 nonce:用于让"重启前发出的审批卡片"失效(编号会跨进程重置)
|
|
398
|
-
this._approvalNonce = randomUUID()
|
|
399
|
-
this._mediaDir = null // 进程私有的附件临时目录(mkdtemp 创建,0700)
|
|
400
|
-
this._lastPeer = null // { peerId, scope, senderId, messageId }
|
|
401
|
-
this._inTurn = false
|
|
402
|
-
this._turnText = '' // 本轮已累积的正文(单聊流式用)
|
|
403
|
-
this._activityLine = '' // 轮次中的活动状态行(如 `⌨️ 正在运行命令(npm test)`)
|
|
404
|
-
this._turnPeerKey = '' // 本轮流式目标 `${scope}:${peerId}`
|
|
405
|
-
|
|
406
|
-
// 事件仅属于本实例(旧事件无 instanceId 时按单实例兼容放行)
|
|
407
|
-
const forThisInstance = (event) => !event?.instanceId || event.instanceId === this.instanceId
|
|
408
|
-
|
|
409
|
-
// 订阅网关入站消息事件
|
|
410
|
-
this.disposers.push(this.ctx.on('qq/message', (event) => {
|
|
411
|
-
if (!forThisInstance(event)) return
|
|
412
|
-
return this._handleInbound(event)
|
|
413
|
-
}))
|
|
414
|
-
|
|
415
|
-
// 订阅按钮点击交互事件(审批 / 问答)
|
|
416
|
-
this.disposers.push(this.ctx.on('qq/action', (event) => {
|
|
417
|
-
if (!forThisInstance(event)) return
|
|
418
|
-
return this._handleAction(event)
|
|
419
|
-
}))
|
|
420
|
-
|
|
421
|
-
// 连接断开/错误:收尾在途流,避免流式会话悬挂
|
|
422
|
-
this.disposers.push(this.ctx.on('qq/status', (status) => {
|
|
423
|
-
if (status?.instanceId && status.instanceId !== this.instanceId) return
|
|
424
|
-
if (status?.status === 'idle' || status?.status === 'error' || status?.status === 'offline') {
|
|
425
|
-
this._abortStream().catch(() => {})
|
|
426
|
-
}
|
|
427
|
-
}))
|
|
428
|
-
|
|
429
|
-
// 监听轮次事件:turn/start 重置流式状态,turn/end 收尾
|
|
430
|
-
this.disposers.push(this.ctx.on('session/event', (session, event) => {
|
|
431
|
-
if (session.id !== this.activeSessionId) return
|
|
432
|
-
if (event.type === 'turn/start') {
|
|
433
|
-
// 兜底:上一轮若因异常没收到 turn/end,这里先把残留正文收尾,
|
|
434
|
-
// 否则"断流等 done"的正文会永远发不出去(_turnText 被清空前先补发)
|
|
435
|
-
if (this._turnText) void this._finishTurnStream()
|
|
436
|
-
this._inTurn = true
|
|
437
|
-
this._turnText = ''
|
|
438
|
-
this._activityLine = ''
|
|
439
|
-
this._turnPeerKey = ''
|
|
440
|
-
} else if (event.type === 'turn/end') {
|
|
441
|
-
this._inTurn = false
|
|
442
|
-
this._activityLine = ''
|
|
443
|
-
void this._finishTurnStream()
|
|
444
|
-
}
|
|
445
|
-
}))
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// ---- 入站 ----
|
|
449
|
-
|
|
450
|
-
async _handleInbound(event) {
|
|
451
|
-
if (this.gateway?.status === 'idle' || this.gateway?.status === 'offline') return
|
|
452
|
-
|
|
453
|
-
const isGroup = event.scope === 'group' || event.isGroup === true
|
|
454
|
-
const peerId = String(event.peerId ?? '')
|
|
455
|
-
const senderId = String(event.senderId ?? '')
|
|
456
|
-
if (!peerId || !senderId) return
|
|
457
|
-
|
|
458
|
-
// 记录当前 peer 与被动回复消息 ID(供出站解析目标)
|
|
459
|
-
this._lastPeer = {
|
|
460
|
-
peerId,
|
|
461
|
-
scope: isGroup ? 'group' : 'c2c',
|
|
462
|
-
senderId,
|
|
463
|
-
messageId: event.messageId ?? '',
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// 授权主体:群聊按群维度(group_openid),单聊按用户(user_openid)
|
|
467
|
-
const authId = isGroup ? (event.groupId || peerId) : senderId
|
|
468
|
-
|
|
469
|
-
// 快速预检查:白名单非空时,未授权单聊发件人直接忽略;
|
|
470
|
-
// 群聊不在此拦截——交给 handleInbound 自动授权(首次 @机器人 即授权该群)
|
|
471
|
-
if (!isGroup && !this.isAllowed(authId) && this.config.allowFrom.length > 0) {
|
|
472
|
-
this.logger?.info?.(`[dsh-bridge qq] ignore message from non-allowlisted sender ${authId}`)
|
|
473
|
-
return
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
let fullText = String(event.text ?? '').trim()
|
|
477
|
-
|
|
478
|
-
// 附件下载(图片/文件/语音)落盘后把路径写进正文,供 agent 读取
|
|
479
|
-
const attachments = Array.isArray(event.attachments) ? event.attachments : []
|
|
480
|
-
let mediaFiles = []
|
|
481
|
-
if (attachments.length > 0) {
|
|
482
|
-
mediaFiles = await this._processAttachments(attachments, this._activeSessionCwd())
|
|
483
|
-
if (mediaFiles.length > 0) {
|
|
484
|
-
this.logger?.info?.(`[dsh-bridge qq] downloaded ${mediaFiles.length} attachment(s) from ${senderId}`)
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
// 语音消息:QQ 服务端已给出 ASR 文本,直接作为正文(用户不必手打)
|
|
488
|
-
if (!fullText) {
|
|
489
|
-
const asr = attachments
|
|
490
|
-
.map((att) => att?.asr_refer_text || att?.asrReferText || '')
|
|
491
|
-
.filter((text) => typeof text === 'string' && text.trim())
|
|
492
|
-
.join('\n')
|
|
493
|
-
if (asr) fullText = `[语音] ${asr.trim()}`
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
if (!fullText && mediaFiles.length === 0) {
|
|
497
|
-
this.logger?.info?.(`[dsh-bridge qq] ignore empty message from ${senderId}`)
|
|
498
|
-
return
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (mediaFiles.length > 0) {
|
|
502
|
-
const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
|
|
503
|
-
fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// 引用消息:把被引用原文作为上下文(SDK 已从 msg_elements 还原)
|
|
507
|
-
const quoted = typeof event.quotedText === 'string' ? event.quotedText.trim() : ''
|
|
508
|
-
if (quoted) fullText = `[引用消息] ${quoted}\n\n${fullText}`
|
|
509
|
-
|
|
510
|
-
// 群历史缓冲:SDK historyBuffer 记录的「未被 @ 的群聊」作为上下文,
|
|
511
|
-
// 否则模型只看到被 @ 的那一句、接不上前面在聊什么
|
|
512
|
-
const history = Array.isArray(event.history) ? event.history : []
|
|
513
|
-
if (isGroup && history.length > 0) {
|
|
514
|
-
const lines = history
|
|
515
|
-
.map((entry) => {
|
|
516
|
-
const who = String(entry?.senderName || entry?.senderId || '群成员')
|
|
517
|
-
return `${who}: ${String(entry?.content ?? '').trim()}`
|
|
518
|
-
})
|
|
519
|
-
.filter((line) => line.length > 0)
|
|
520
|
-
if (lines.length > 0) fullText = `[群最近消息]\n${lines.join('\n')}\n\n${fullText}`
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// 群聊标注发送者昵称:让 agent 知道"谁在说话"
|
|
524
|
-
const senderName = String(event.senderName ?? '')
|
|
525
|
-
if (isGroup && senderName) fullText = `[来自 ${senderName}] ${fullText}`
|
|
526
|
-
|
|
527
|
-
this.logger?.debug?.(`[dsh-bridge qq] inbound from ${authId} (group=${isGroup}): ${fullText.slice(0, 200)}`)
|
|
528
|
-
|
|
529
|
-
return this.handleInbound({
|
|
530
|
-
senderId: authId,
|
|
531
|
-
isGroup,
|
|
532
|
-
text: fullText,
|
|
533
|
-
senderLabel: senderName,
|
|
534
|
-
outboundPeer: {
|
|
535
|
-
peerId,
|
|
536
|
-
scope: isGroup ? 'group' : 'c2c',
|
|
537
|
-
isGroup,
|
|
538
|
-
messageId: event.messageId ?? '',
|
|
539
|
-
},
|
|
540
|
-
})
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* 下载附件(图片/文件/语音)并保存。
|
|
545
|
-
*
|
|
546
|
-
* 安全要点(CodeQL js/insecure-temporary-file,high):
|
|
547
|
-
* 每个进程用 `mkdtemp` 建一个**权限 0700 的随机目录**存放附件,不写进会话工作区里
|
|
548
|
-
* 可预测的 `.qq-media/`。原因:固定目录 + 可预测文件名(`qq_<时间戳>_<随机>.png`)
|
|
549
|
-
* 在多用户机器上可被预先创建符号链接 / 抢先占位,导致写入越权或覆盖他人文件。
|
|
550
|
-
* 文件名再做 basename + 控制/非法字符清洗,最后限死在该目录内。
|
|
551
|
-
*
|
|
552
|
-
* 目录路径也会塞进正文给 agent 读,所以必须稳定可读、跨轮复用(同一进程内缓存)。
|
|
553
|
-
*/
|
|
554
|
-
async _ensureMediaDir() {
|
|
555
|
-
if (this._mediaDir) return this._mediaDir
|
|
556
|
-
const base = path.join(os.tmpdir(), 'dsh-bridge-qq-')
|
|
557
|
-
// mkdtemp 自带随机后缀且以 0700 创建,天然防预测
|
|
558
|
-
const dir = await fs.promises.mkdtemp(base)
|
|
559
|
-
this._mediaDir = dir
|
|
560
|
-
return dir
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/** 下载附件(图片/文件/语音)并保存到进程私有临时目录 */
|
|
564
|
-
async _processAttachments(attachments, sessionCwd) {
|
|
565
|
-
if (!Array.isArray(attachments) || attachments.length === 0) return []
|
|
566
|
-
let mediaDir
|
|
567
|
-
try {
|
|
568
|
-
mediaDir = await this._ensureMediaDir()
|
|
569
|
-
} catch (err) {
|
|
570
|
-
this.logger?.warn?.('[dsh-bridge qq] 附件临时目录创建失败:%s', err?.message ?? err)
|
|
571
|
-
return []
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
const downloaded = []
|
|
575
|
-
for (const att of attachments) {
|
|
576
|
-
const rawUrl = String(att?.url ?? '')
|
|
577
|
-
if (!rawUrl) continue
|
|
578
|
-
if (isBlockedDownloadUrl(rawUrl)) {
|
|
579
|
-
this.logger?.warn?.('[dsh-bridge qq] attachment url rejected (private/loopback): %s', rawUrl.slice(0, 80))
|
|
580
|
-
continue
|
|
581
|
-
}
|
|
582
|
-
// 解析后二次校验(防 DNS 解析绕过:127.0.0.1.nip.io 之类字符串合法、实际指向内网)
|
|
583
|
-
if (!(await isHostResolvableToPublic(new URL(rawUrl).hostname.toLowerCase().replace(/^\[|\]$/g, '')))) {
|
|
584
|
-
this.logger?.warn?.('[dsh-bridge qq] attachment host resolves to private address, rejected: %s', rawUrl.slice(0, 80))
|
|
585
|
-
continue
|
|
586
|
-
}
|
|
587
|
-
try {
|
|
588
|
-
// redirect: 'manual' —— 不跟随重定向,避免"公网 URL 302 到 127.0.0.1"的绕过
|
|
589
|
-
const res = await fetch(rawUrl, { signal: AbortSignal.timeout(15_000), redirect: 'manual' })
|
|
590
|
-
if (!res.ok) {
|
|
591
|
-
this.logger?.warn?.('[dsh-bridge qq] attachment download failed: HTTP %s', res.status)
|
|
592
|
-
continue
|
|
593
|
-
}
|
|
594
|
-
// 先看 Content-Length,再按上限流式读取(避免超大响应先占满内存)
|
|
595
|
-
const declared = Number(res.headers.get('content-length'))
|
|
596
|
-
if (Number.isFinite(declared) && declared > MAX_ATTACHMENT_BYTES) {
|
|
597
|
-
this.logger?.warn?.('[dsh-bridge qq] attachment too large (%d bytes > %d), skipped', declared, MAX_ATTACHMENT_BYTES)
|
|
598
|
-
continue
|
|
599
|
-
}
|
|
600
|
-
const buf = await readBodyWithLimit(res, MAX_ATTACHMENT_BYTES)
|
|
601
|
-
if (buf === null) {
|
|
602
|
-
this.logger?.warn?.('[dsh-bridge qq] attachment exceeded %d bytes while streaming, skipped', MAX_ATTACHMENT_BYTES)
|
|
603
|
-
continue
|
|
604
|
-
}
|
|
605
|
-
if (buf.length === 0) {
|
|
606
|
-
// 空响应不是有效附件:写 0 字节文件只会让后续 agent 读到空文件(复核 P2)
|
|
607
|
-
this.logger?.warn?.('[dsh-bridge qq] attachment body is empty, skipped: %s', rawUrl.slice(0, 80))
|
|
608
|
-
continue
|
|
609
|
-
}
|
|
610
|
-
const filename = String(att.filename ?? '').trim()
|
|
611
|
-
const ext = filename
|
|
612
|
-
? path.extname(filename)
|
|
613
|
-
: (String(att.content_type ?? att.contentType ?? '').includes('image') ? '.png' : '.bin')
|
|
614
|
-
// basename 去掉任何路径成分;再兜底清理非法字符与控制字符
|
|
615
|
-
const safeName = (filename ? path.basename(filename) : `qq_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`)
|
|
616
|
-
// 控制字符 + Windows/POSIX 非法文件名字符统一替换(按 code point 判断,避免正则里的 \x00 字面量)
|
|
617
|
-
.replace(/[\s\S]/g, (ch) => {
|
|
618
|
-
const code = ch.codePointAt(0)
|
|
619
|
-
if (code < 0x20 || code === 0x7f) return '_'
|
|
620
|
-
return '/\\:*?"<>|'.includes(ch) ? '_' : ch
|
|
621
|
-
}) || `qq_${Date.now()}${ext}`
|
|
622
|
-
const filePath = path.join(mediaDir, safeName)
|
|
623
|
-
// 二次校验:拼接后必须仍在 mediaDir 内(防 .. / 绝对路径 / 符号链接逃逸)
|
|
624
|
-
if (!path.resolve(filePath).startsWith(path.resolve(mediaDir) + path.sep)) {
|
|
625
|
-
this.logger?.warn?.('[dsh-bridge qq] attachment path escapes media dir, skipped: %s', safeName)
|
|
626
|
-
continue
|
|
627
|
-
}
|
|
628
|
-
// 独占创建(flag 'wx'):目标已存在则失败而不是静默覆盖。极端撞名时加随机后缀重试一次。
|
|
629
|
-
// CodeQL js/http-to-file-access 会标记"HTTP 响应体写入文件"——这正是本功能的目的
|
|
630
|
-
// (把用户发来的附件落到本地供 agent 读)。风险已通过三重约束收敛:
|
|
631
|
-
// 1) URL 先过 isBlockedDownloadUrl(私网/非 http(s) 拒绝);
|
|
632
|
-
// 2) 再过 isHostResolvableToPublic(DNS 解析后 IP 校验,防 rebinding);
|
|
633
|
-
// 3) 目标目录是 mkdtemp 的进程私有 0700 目录,文件名经 basename + 非法字符清洗,
|
|
634
|
-
// 并二次校验必须落在该目录内;写入用 wx 独占、0600 权限。
|
|
635
|
-
const target = await writeAttachmentExclusive(mediaDir, safeName, ext, buf)
|
|
636
|
-
downloaded.push({ filename: path.basename(target), path: target, size: buf.length })
|
|
637
|
-
} catch (err) {
|
|
638
|
-
this.logger?.warn?.('[dsh-bridge qq] download attachment error: %s', err?.message ?? err)
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
return downloaded
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/** 当前会话的工作目录(用于附件落盘与发文件白名单) */
|
|
645
|
-
_activeSessionCwd() {
|
|
646
|
-
try {
|
|
647
|
-
const session = this.ctx?.sessions?.get?.(this.activeSessionId)
|
|
648
|
-
const cwd = session?.header?.cwd || session?.cwd
|
|
649
|
-
if (typeof cwd === 'string' && cwd) return cwd
|
|
650
|
-
} catch { /* 读不到就用配置值 */ }
|
|
651
|
-
return this.config.cwd || process.cwd()
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// ---- 交互事件(按钮) ----
|
|
655
|
-
|
|
656
|
-
async _handleAction(event) {
|
|
657
|
-
// 任何**提前返回都必须先应答**,否则 QQ 客户端会一直 loading 到超时。
|
|
658
|
-
const ack = (code) => this.gateway?.acknowledgeInteraction?.(event.interactionId, code).catch?.(() => {})
|
|
659
|
-
|
|
660
|
-
if (this.gateway?.status === 'idle' || this.gateway?.status === 'offline') {
|
|
661
|
-
await ack(INTERACTION_ACK.FAILED)
|
|
662
|
-
return
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
const isGroup = event.scope === 'group'
|
|
666
|
-
// 授权主体:与消息处理一致(群按群维度、单聊按用户)
|
|
667
|
-
const authId = isGroup ? (event.groupId || event.peerId) : String(event.senderId ?? '')
|
|
668
|
-
if (!isGroup && !this.isAllowed(authId) && this.config.allowFrom.length > 0) {
|
|
669
|
-
this.logger?.info?.(`[dsh-bridge qq] ignore interaction from non-allowlisted sender ${authId}`)
|
|
670
|
-
await ack(INTERACTION_ACK.NO_PERMISSION)
|
|
671
|
-
return
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const data = event.buttonData
|
|
675
|
-
const type = typeof data === 'object' && data !== null ? data.t : ''
|
|
676
|
-
|
|
677
|
-
// 记录 peer 信息,供命令回复使用
|
|
678
|
-
this._lastPeer = {
|
|
679
|
-
peerId: String(event.peerId ?? ''),
|
|
680
|
-
scope: isGroup ? 'group' : 'c2c',
|
|
681
|
-
senderId: String(event.senderId ?? ''),
|
|
682
|
-
messageId: event.messageId ?? '',
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// 决议结果为 { ok, code, reason }:code 是**要回给平台的应答码**。
|
|
686
|
-
// 绝不能把"无对应待处理项"当成 REPEAT(3)——平台会弹「请勿重复操作」误导用户(实测踩过)。
|
|
687
|
-
let result = { ok: false, code: INTERACTION_ACK.FAILED, reason: 'unknown-button-type' }
|
|
688
|
-
try {
|
|
689
|
-
if (type === 'approval') {
|
|
690
|
-
result = this._resolveApprovalByButton(data, authId, isGroup, String(event.peerId ?? ''))
|
|
691
|
-
} else if (type === 'question') {
|
|
692
|
-
result = this._resolveAskByButton(data, authId, isGroup, String(event.peerId ?? ''))
|
|
693
|
-
}
|
|
694
|
-
} catch (err) {
|
|
695
|
-
this.logger?.warn?.('[dsh-bridge qq] 按钮处理失败:%s', err?.message ?? err)
|
|
696
|
-
result = { ok: false, code: INTERACTION_ACK.FAILED, reason: 'exception' }
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
// QQ 要求 5 秒内应答,否则客户端一直 loading
|
|
700
|
-
await this.gateway?.acknowledgeInteraction?.(event.interactionId, result.code)
|
|
701
|
-
.catch?.(() => {})
|
|
702
|
-
|
|
703
|
-
if (!result.ok) {
|
|
704
|
-
// 分级记录:无权限用 warn(安全相关),其余 info(多为过期卡片/未知按钮)
|
|
705
|
-
const level = result.code === INTERACTION_ACK.NO_PERMISSION ? 'warn' : 'info'
|
|
706
|
-
this.logger?.[level]?.('[dsh-bridge qq] 按钮未生效(%s,应答码=%s):%s',
|
|
707
|
-
result.reason, result.code, JSON.stringify(data))
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* 审批按钮决议。
|
|
713
|
-
*
|
|
714
|
-
* 归属模型(P0-3):`button_data` 里带审批编号,按编号**精确**取 pending,
|
|
715
|
-
* 绝不能用"最后一个 pending"——否则 A 群成员点一下就能批准 B 群挂起的审批。
|
|
716
|
-
* 归属校验与文字 `/yes` `/no` 路径一致:单聊限发起者本人,群聊限**该群**成员。
|
|
717
|
-
*
|
|
718
|
-
* @param {object} data button_data(`{t:'approval', n:<编号>, d:'allow'|'deny'}`)
|
|
719
|
-
* @param {string} senderId 点击者 openid
|
|
720
|
-
* @param {boolean} isGroup
|
|
721
|
-
* @param {string} [groupId] 点击发生的群(群聊时必传,用于校验归属)
|
|
722
|
-
*/
|
|
723
|
-
_resolveApprovalByButton(data, senderId, isGroup, groupId = '') {
|
|
724
|
-
const decision = data?.d
|
|
725
|
-
const outcome = decision === 'allow' ? 'allowed-once' : decision === 'deny' ? 'rejected' : ''
|
|
726
|
-
if (!outcome) return { ok: false, code: INTERACTION_ACK.FAILED, reason: 'bad-decision' }
|
|
727
|
-
|
|
728
|
-
const number = Number(data?.n)
|
|
729
|
-
// 没有编号(旧卡片/异常数据):拒绝处理,绝不猜测"最后一个 pending"
|
|
730
|
-
if (!Number.isInteger(number)) {
|
|
731
|
-
this.logger?.warn?.('[dsh-bridge qq] approval button without id, ignored: %s', JSON.stringify(data))
|
|
732
|
-
return { ok: false, code: INTERACTION_ACK.FAILED, reason: 'missing-approval-id' }
|
|
733
|
-
}
|
|
734
|
-
// 进程 nonce 校验(B3):审批编号在每个进程里从 1 重来,重启后旧卡片上的 n=1
|
|
735
|
-
// 会命中新审批 #1。带 nonce 后旧卡片一律失效。
|
|
736
|
-
const cardNonce = String(data?.s ?? '')
|
|
737
|
-
if (cardNonce && cardNonce !== this._approvalNonce) {
|
|
738
|
-
this.logger?.warn?.('[dsh-bridge qq] approval #%s 卡片来自旧进程(nonce 不匹配),忽略点击', number)
|
|
739
|
-
return false
|
|
740
|
-
}
|
|
741
|
-
const entry = this.pending.get(number)
|
|
742
|
-
if (!entry) {
|
|
743
|
-
this.logger?.info?.('[dsh-bridge qq] approval #%s 按钮点击时审批已结束(或已超时)', number)
|
|
744
|
-
return { ok: false, code: INTERACTION_ACK.FAILED, reason: 'approval-not-pending' }
|
|
745
|
-
}
|
|
746
|
-
// 归属校验:审批只在**它所属的会话**里决议
|
|
747
|
-
if (isGroup) {
|
|
748
|
-
const owner = String(entry.peerId ?? '')
|
|
749
|
-
const clicked = String(groupId ?? '')
|
|
750
|
-
if (owner && clicked && owner !== clicked) {
|
|
751
|
-
this.logger?.warn?.('[dsh-bridge qq] approval #%s 属于群 %s,拒绝来自群 %s 的点击', number, owner, clicked)
|
|
752
|
-
return { ok: false, code: INTERACTION_ACK.NO_PERMISSION, reason: 'wrong-group' }
|
|
753
|
-
}
|
|
754
|
-
} else if (entry.peerId && entry.peerId !== senderId) {
|
|
755
|
-
this.logger?.warn?.('[dsh-bridge qq] approval #%s blocked: operator %s is not the initiator %s', number, senderId, entry.peerId)
|
|
756
|
-
return { ok: false, code: INTERACTION_ACK.NO_PERMISSION, reason: 'not-initiator' }
|
|
757
|
-
}
|
|
758
|
-
this.clearApproval(number)
|
|
759
|
-
entry.resolve(outcome)
|
|
760
|
-
this.logger?.info?.('[dsh-bridge qq] approval #%s resolved by button: %s', number, outcome)
|
|
761
|
-
return { ok: true, code: INTERACTION_ACK.SUCCESS }
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* 问答按钮决议:单问单选。
|
|
766
|
-
*
|
|
767
|
-
* 归属校验与审批**完全同模型**(C 项回归):单聊限发起者本人,群聊限**该群**成员。
|
|
768
|
-
* 此前群聊分支完全没校验,g2 的成员点一下就能回答 g1 的提问(实测已决议)。
|
|
769
|
-
*/
|
|
770
|
-
_resolveAskByButton(data, senderId, isGroup, groupId = '') {
|
|
771
|
-
const askId = Number(data?.q)
|
|
772
|
-
const idx = Number(data?.i)
|
|
773
|
-
const entry = this.getAsk(askId)
|
|
774
|
-
if (!entry) return { ok: false, code: INTERACTION_ACK.FAILED, reason: 'ask-not-pending' }
|
|
775
|
-
if (isGroup) {
|
|
776
|
-
const owner = String(entry.peerId ?? '')
|
|
777
|
-
const clicked = String(groupId ?? '')
|
|
778
|
-
if (owner && clicked && owner !== clicked) {
|
|
779
|
-
this.logger?.warn?.('[dsh-bridge qq] ask #%s 属于群 %s,拒绝来自群 %s 的点击', askId, owner, clicked)
|
|
780
|
-
return { ok: false, code: INTERACTION_ACK.NO_PERMISSION, reason: 'wrong-group' }
|
|
781
|
-
}
|
|
782
|
-
} else if (entry.peerId && entry.peerId !== senderId) {
|
|
783
|
-
this.logger?.warn?.('[dsh-bridge qq] ask #%s blocked: operator %s is not the initiator %s', askId, senderId, entry.peerId)
|
|
784
|
-
return { ok: false, code: INTERACTION_ACK.NO_PERMISSION, reason: 'not-initiator' }
|
|
785
|
-
}
|
|
786
|
-
const question = entry.questions?.[0]
|
|
787
|
-
const options = Array.isArray(question?.options) ? question.options : []
|
|
788
|
-
if (!Number.isInteger(idx) || idx < 0 || idx >= options.length) {
|
|
789
|
-
return { ok: false, code: INTERACTION_ACK.FAILED, reason: 'bad-option' }
|
|
790
|
-
}
|
|
791
|
-
entry.resolve({ answers: [{ id: question.id, selected: [String(options[idx]?.label ?? '')] }] })
|
|
792
|
-
this.logger?.info?.('[dsh-bridge qq] ask #%s resolved by button: option %s', askId, idx)
|
|
793
|
-
return { ok: true, code: INTERACTION_ACK.SUCCESS }
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
// ---- 出站:覆盖 sendText 的分块与流式 ----
|
|
797
|
-
|
|
798
|
-
/**
|
|
799
|
-
* 出站目标解析(覆盖基类):优先本轮绑定的 outboundPeer,其次最近一次入站。
|
|
800
|
-
* @returns {{ peerId: string, scope: 'c2c'|'group', msgId?: string }|null}
|
|
801
|
-
*/
|
|
802
|
-
resolveOutboundPeer(opts = {}) {
|
|
803
|
-
const bound = opts?.outboundPeer
|
|
804
|
-
if (bound?.peerId) {
|
|
805
|
-
return {
|
|
806
|
-
peerId: String(bound.peerId),
|
|
807
|
-
scope: bound.scope === 'group' ? 'group' : 'c2c',
|
|
808
|
-
msgId: bound.messageId || undefined,
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
if (this._lastPeer?.peerId) {
|
|
812
|
-
return {
|
|
813
|
-
peerId: this._lastPeer.peerId,
|
|
814
|
-
scope: this._lastPeer.scope,
|
|
815
|
-
msgId: this._lastPeer.messageId || undefined,
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
return null
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/**
|
|
822
|
-
* 出站文本。
|
|
823
|
-
*
|
|
824
|
-
* - 轮次内(turn 期间)单聊:累积分片并用官方 `stream_messages` 原地改写同一条消息;
|
|
825
|
-
* - 轮次内群聊:平台不支持流式,逐条发 Markdown;
|
|
826
|
-
* - 轮次外(命令响应 / 系统提示 / 进度摘要):按 maxMessageChars 分块发 Markdown。
|
|
827
|
-
*
|
|
828
|
-
* `opts.standalone=true`(本轮改动摘要等轮次级通知)必须独立成条,
|
|
829
|
-
* 不得并入流式正文——否则会被 turn/end 的收尾覆盖掉。
|
|
830
|
-
*/
|
|
831
|
-
async _sendTextNow(text, opts = {}) {
|
|
832
|
-
// 离线守卫:网关未连接时不尝试出站(避免把消息堆到重连后的错误目标)
|
|
833
|
-
if (this.platform?.status === 'idle' || this.platform?.status === 'offline') return
|
|
834
|
-
const peer = this.resolveOutboundPeer(opts)
|
|
835
|
-
if (!peer) {
|
|
836
|
-
this.logger?.warn?.('[dsh-bridge qq] 出站目标为空,本条已跳过:%s', String(text ?? '').slice(0, 80))
|
|
837
|
-
return
|
|
838
|
-
}
|
|
839
|
-
const content = String(text ?? '').trim()
|
|
840
|
-
if (!content) return
|
|
841
|
-
|
|
842
|
-
// 轮次内单聊:流式累积
|
|
843
|
-
if (this._inTurn && !opts?.standalone && peer.scope === 'c2c' && this.config.streamReply !== false) {
|
|
844
|
-
await this._streamTurnText(peer, content)
|
|
845
|
-
return
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
const md = sanitizeQQMarkdown(content)
|
|
849
|
-
const chunks = conversationBridgeHelpers.splitForIM(md, this.config.maxMessageChars || 2000)
|
|
850
|
-
if (chunks.length === 0) return
|
|
851
|
-
// sendMarkdown 是 QQ 的常规出站路径;gateway 未提供时降级 sendText(便于最小实现/测试替身)
|
|
852
|
-
const send = typeof this.gateway?.sendMarkdown === 'function'
|
|
853
|
-
? (peerId, body, sendOpts) => this.gateway.sendMarkdown(peerId, body, sendOpts)
|
|
854
|
-
: (peerId, body, sendOpts) => this.gateway.sendText(peerId, body, sendOpts)
|
|
855
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
856
|
-
const result = await send(peer.peerId, chunks[i], {
|
|
857
|
-
scope: peer.scope,
|
|
858
|
-
msgId: peer.msgId,
|
|
859
|
-
streamKey: opts?.streamKey,
|
|
860
|
-
})
|
|
861
|
-
if (result && result.success === false) {
|
|
862
|
-
this.logger?.warn?.('[dsh-bridge qq] outbound chunk %d/%d failed: %s', i + 1, chunks.length, result.error)
|
|
863
|
-
break
|
|
864
|
-
}
|
|
865
|
-
if (i < chunks.length - 1 && this.config.sendChunkDelayMs > 0) {
|
|
866
|
-
await new Promise((resolve) => setTimeout(resolve, this.config.sendChunkDelayMs))
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
/**
|
|
872
|
-
* 轮次内累积 + 流式写入。
|
|
873
|
-
*
|
|
874
|
-
* 宿主的 `assistant/message` 是**每步的文本片段**(surfaceOp=append),不是整轮全文,
|
|
875
|
-
* 因此这里必须累积;已渲染内容以片段开头时按替换处理(兼容宿主已累积的形态)。
|
|
876
|
-
* 详见飞书侧同类注释(同一坑,2026-10-16 真机定位)。
|
|
877
|
-
*/
|
|
878
|
-
async _streamTurnText(peer, content) {
|
|
879
|
-
const prev = this._turnText
|
|
880
|
-
const merged = !prev
|
|
881
|
-
? content
|
|
882
|
-
: (content.startsWith(prev) ? content : `${prev}\n\n${content}`)
|
|
883
|
-
this._turnText = merged
|
|
884
|
-
this._turnPeerKey = `${peer.scope}:${peer.peerId}`
|
|
885
|
-
|
|
886
|
-
const payload = this._withStatus(sanitizeQQMarkdown(merged))
|
|
887
|
-
const result = await this.gateway.sendStream(peer.peerId, payload, {
|
|
888
|
-
scope: peer.scope,
|
|
889
|
-
msgId: peer.msgId,
|
|
890
|
-
streamKey: this._turnPeerKey,
|
|
891
|
-
})
|
|
892
|
-
if (result && result.success === false) {
|
|
893
|
-
this.logger?.warn?.('[dsh-bridge qq] 流式输出失败:%s', result.error)
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
/** 收尾本轮流式消息(幂等):写最终内容并标记 input_state=DONE。 */
|
|
898
|
-
async _finishTurnStream() {
|
|
899
|
-
const text = this._turnText
|
|
900
|
-
const key = this._turnPeerKey
|
|
901
|
-
this._turnText = ''
|
|
902
|
-
this._turnPeerKey = ''
|
|
903
|
-
if (!key || !text) return
|
|
904
|
-
const sepIndex = key.indexOf(':')
|
|
905
|
-
const scope = key.slice(0, sepIndex)
|
|
906
|
-
const peerId = key.slice(sepIndex + 1)
|
|
907
|
-
if (!peerId || scope !== 'c2c') return
|
|
908
|
-
try {
|
|
909
|
-
await this.gateway.sendStream(peerId, sanitizeQQMarkdown(text), { scope, done: true, streamKey: key })
|
|
910
|
-
} catch (err) {
|
|
911
|
-
this.logger?.warn?.('[dsh-bridge qq] 流式收尾失败:%s', err?.message ?? err)
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
/**
|
|
916
|
-
* 连接断开时中止在途流。
|
|
917
|
-
*
|
|
918
|
-
* 不发送 DONE 帧(连接已断,写 socket 只会失败刷日志);但**不清空 `_turnText`**——
|
|
919
|
-
* 旧实现直接赋值 '',断线瞬间已生成的正文彻底丢失,比重连后至少补发半截还差
|
|
920
|
-
* (复核 B2 实测 lost:true)。这里保留正文,交给下一次 turn/start 或 dispose 补发。
|
|
921
|
-
*/
|
|
922
|
-
async _abortStream() {
|
|
923
|
-
const key = this._turnPeerKey
|
|
924
|
-
this._turnPeerKey = ''
|
|
925
|
-
this._inTurn = false
|
|
926
|
-
if (!key) return
|
|
927
|
-
const sepIndex = key.indexOf(':')
|
|
928
|
-
const scope = key.slice(0, sepIndex)
|
|
929
|
-
const peerId = key.slice(sepIndex + 1)
|
|
930
|
-
// abort:不发 DONE 帧(连接已断,写 socket 只会失败并刷日志),只取消挂起的节流推送
|
|
931
|
-
if (peerId) await this.gateway.finishStream?.(peerId, { scope, streamKey: key, abort: true }).catch(() => {})
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
/** 把活动状态行拼到正文前(与飞书/Telegram 同一呈现:引用块 + 斜体)。 */
|
|
935
|
-
_withStatus(text) {
|
|
936
|
-
const status = this._activityLine
|
|
937
|
-
if (!status) return text
|
|
938
|
-
const styled = `${STATUS_PREFIX}${status}_`
|
|
939
|
-
return text ? `${styled}\n\n${text}` : styled
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
/**
|
|
943
|
-
* 更新"当前活动"状态(由会话桥在 tool/call 时调用)。
|
|
944
|
-
* 立即刷新已渲染内容,让用户看到 agent 正在做什么。
|
|
945
|
-
*/
|
|
946
|
-
async setActivityStatus(line, _opts, parts) {
|
|
947
|
-
this._activityLine = parts ? this._styleActivityParts(parts) : String(line ?? '')
|
|
948
|
-
if (!this._activityLine) return
|
|
949
|
-
// 本轮还没有正文:单独发一条状态消息,避免整段时间用户看不到进度
|
|
950
|
-
if (!this._turnText) {
|
|
951
|
-
const peer = this.resolveOutboundPeer(_opts ?? {})
|
|
952
|
-
if (!peer) return
|
|
953
|
-
try {
|
|
954
|
-
await this.gateway.sendMarkdown(peer.peerId, this._withStatus(''), {
|
|
955
|
-
scope: peer.scope,
|
|
956
|
-
msgId: peer.msgId,
|
|
957
|
-
})
|
|
958
|
-
} catch (err) {
|
|
959
|
-
this.logger?.debug?.('[dsh-bridge qq] 状态行发送失败:%s', err?.message ?? err)
|
|
960
|
-
}
|
|
961
|
-
return
|
|
962
|
-
}
|
|
963
|
-
// 已有正文:随下一次流式更新自然带上(不在中间插入,避免消息抖动)
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/** 受控文案保持原样、detail 转义后组装(避免命令原文破坏样式)。 */
|
|
967
|
-
_styleActivityParts(parts) {
|
|
968
|
-
const icon = String(parts?.icon ?? '')
|
|
969
|
-
const text = String(parts?.text ?? '')
|
|
970
|
-
const suffix = String(parts?.suffix ?? '')
|
|
971
|
-
const detail = parts?.detail ? String(parts.detail).replace(/[*_`[\]()]/g, '') : ''
|
|
972
|
-
const head = detail ? `${icon} ${text}(${detail})` : `${icon} ${text}`
|
|
973
|
-
return `${head}${suffix}`
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/** 输入状态:QQ 仅 C2C 支持(网关内部会跳过群聊)。 */
|
|
977
|
-
async sendTyping(state, opts = {}) {
|
|
978
|
-
const peer = this._lastPeer
|
|
979
|
-
const peerId = opts?.outboundPeer?.peerId ?? peer?.peerId
|
|
980
|
-
if (!peerId) return
|
|
981
|
-
// state=2(停止)无需显式结束——input_second 到期自动消失
|
|
982
|
-
if (Number(state) === 2) return
|
|
983
|
-
const scope = opts?.outboundPeer?.scope ?? peer?.scope ?? 'c2c'
|
|
984
|
-
return this.gateway.sendTyping(peerId, { scope, durationSeconds: 8 })
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
/** 发送多媒体附件(沿用当前 peer)。 */
|
|
988
|
-
async sendMediaFile(filePath, opts = {}) {
|
|
989
|
-
const peer = this.resolveOutboundPeer(opts)
|
|
990
|
-
if (!peer) return { success: false, error: '无可用出站目标' }
|
|
991
|
-
return this.gateway.sendMediaFile(peer.peerId, filePath, { scope: peer.scope, msgId: peer.msgId })
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
/**
|
|
995
|
-
* 审批卡片:Markdown + 两个回调按钮(允许一次 / 拒绝)。
|
|
996
|
-
*
|
|
997
|
-
* 会话桥在 `approval/request` 里优先调用本方法(存在时),失败自动回退纯文字提示。
|
|
998
|
-
* button_data 编码 `{"t":"approval","d":"allow"|"deny"}`,由 _handleAction 统一路由。
|
|
999
|
-
*
|
|
1000
|
-
* @returns {Promise<boolean>} 是否成功送达卡片
|
|
1001
|
-
*/
|
|
1002
|
-
async sendApprovalCard(peer, approval = {}, promptText = '') {
|
|
1003
|
-
// 桥传入的 outboundPeer 字段是 { peerId, scope, isGroup, messageId };
|
|
1004
|
-
// 统一成出站目标(msgId 用于被动回复锚点,避免退化成主动消息)
|
|
1005
|
-
peer = {
|
|
1006
|
-
peerId: peer?.peerId,
|
|
1007
|
-
scope: peer?.scope ?? (peer?.isGroup ? 'group' : 'c2c'),
|
|
1008
|
-
msgId: peer?.msgId ?? peer?.messageId,
|
|
1009
|
-
}
|
|
1010
|
-
const content = sanitizeQQMarkdown(String(promptText || ''))
|
|
1011
|
-
const keyboard = {
|
|
1012
|
-
content: {
|
|
1013
|
-
rows: [{
|
|
1014
|
-
buttons: [
|
|
1015
|
-
{
|
|
1016
|
-
id: 'approval-allow',
|
|
1017
|
-
render_data: { label: '✅ 允许一次', visited_label: '✓ 已允许', style: 1 },
|
|
1018
|
-
action: {
|
|
1019
|
-
type: 1,
|
|
1020
|
-
permission: { type: 2 },
|
|
1021
|
-
click_limit: 1,
|
|
1022
|
-
data: JSON.stringify({ t: 'approval', n: Number(approval.number) || 0, s: this._approvalNonce, d: 'allow' }),
|
|
1023
|
-
unsupport_tips: '请升级 QQ 客户端后使用按钮',
|
|
1024
|
-
},
|
|
1025
|
-
group_id: 'approval',
|
|
1026
|
-
},
|
|
1027
|
-
{
|
|
1028
|
-
id: 'approval-deny',
|
|
1029
|
-
render_data: { label: '❌ 拒绝', visited_label: '✓ 已拒绝', style: 0 },
|
|
1030
|
-
action: {
|
|
1031
|
-
type: 1,
|
|
1032
|
-
permission: { type: 2 },
|
|
1033
|
-
click_limit: 1,
|
|
1034
|
-
data: JSON.stringify({ t: 'approval', n: Number(approval.number) || 0, s: this._approvalNonce, d: 'deny' }),
|
|
1035
|
-
unsupport_tips: '请升级 QQ 客户端后使用按钮',
|
|
1036
|
-
},
|
|
1037
|
-
group_id: 'approval',
|
|
1038
|
-
},
|
|
1039
|
-
],
|
|
1040
|
-
}],
|
|
1041
|
-
},
|
|
1042
|
-
}
|
|
1043
|
-
// 目标与被动回复锚点都必须来自**本审批所属的会话**(peer);
|
|
1044
|
-
// 回退到 this._lastPeer 会把上一个会话的 msg_id 用到当前对话(P0-3 实测串号)
|
|
1045
|
-
const targetPeerId = peer?.peerId
|
|
1046
|
-
if (!targetPeerId) {
|
|
1047
|
-
this.logger?.warn?.('[dsh-bridge qq] 审批卡片缺少会话目标,改为文字回退')
|
|
1048
|
-
return false
|
|
1049
|
-
}
|
|
1050
|
-
try {
|
|
1051
|
-
const res = await this.gateway.sendMarkdown(targetPeerId, content, {
|
|
1052
|
-
scope: peer?.scope ?? 'c2c',
|
|
1053
|
-
msgId: peer?.msgId,
|
|
1054
|
-
keyboard,
|
|
1055
|
-
})
|
|
1056
|
-
return res?.success !== false
|
|
1057
|
-
} catch (err) {
|
|
1058
|
-
this.logger?.warn?.('[dsh-bridge qq] 审批卡片发送失败(回退文字):%s', err?.message ?? err)
|
|
1059
|
-
return false
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
/**
|
|
1064
|
-
* 问答卡片:Markdown 正文 + 每行 2 个回调按钮(单问单选)。
|
|
1065
|
-
*
|
|
1066
|
-
* 选项全文写进正文、按钮只放短标签——QQ 按钮 label 有长度限制,
|
|
1067
|
-
* 只靠按钮会丢信息(飞书侧已踩过同样的坑)。
|
|
1068
|
-
*
|
|
1069
|
-
* @returns {Promise<boolean>} 是否成功送达卡片
|
|
1070
|
-
*/
|
|
1071
|
-
async sendAskCard(peer, question, entry, promptText = '') {
|
|
1072
|
-
const options = Array.isArray(question?.options) ? question.options : []
|
|
1073
|
-
if (options.length === 0) return false
|
|
1074
|
-
|
|
1075
|
-
const rows = [sanitizeQQMarkdown(String(promptText || ''))]
|
|
1076
|
-
const questionText = String(question?.question ?? '').replace(/\|/g, '|')
|
|
1077
|
-
if (questionText) rows.push('', `**${questionText}**`)
|
|
1078
|
-
const detail = String(question?.detail ?? '').replace(/\|/g, '|').trim()
|
|
1079
|
-
if (detail) rows.push('', detail)
|
|
1080
|
-
options.forEach((opt, i) => {
|
|
1081
|
-
const label = String(opt?.label ?? `选项 ${i + 1}`)
|
|
1082
|
-
const recommended = /\(recommended\)|(推荐)/i.test(label)
|
|
1083
|
-
const desc = String(opt?.description ?? '').trim()
|
|
1084
|
-
rows.push(`${i + 1}. ${recommended ? '⭐ ' : ''}${label}${desc ? ` — ${desc}` : ''}`)
|
|
1085
|
-
})
|
|
1086
|
-
rows.push('', '> 点击下方按钮选择,或直接回复编号')
|
|
1087
|
-
|
|
1088
|
-
const buttons = options.map((opt, i) => {
|
|
1089
|
-
const label = String(opt?.label ?? `选项 ${i + 1}`).trim()
|
|
1090
|
-
const bare = label.replace(/\s*[((]\s*(recommended|推荐)\s*[))]\s*/i, '').trim()
|
|
1091
|
-
const head = bare.split(/[::—\-–((]/)[0].trim() || bare
|
|
1092
|
-
const short = head.length > 8 ? `${head.slice(0, 8)}…` : head
|
|
1093
|
-
return {
|
|
1094
|
-
id: `q-${entry.number}-opt-${i}`,
|
|
1095
|
-
render_data: { label: `${i + 1}. ${short || `选项${i + 1}`}`, visited_label: '✓ 已选', style: 1 },
|
|
1096
|
-
action: {
|
|
1097
|
-
type: 1,
|
|
1098
|
-
permission: { type: 2 },
|
|
1099
|
-
click_limit: 1,
|
|
1100
|
-
data: JSON.stringify({ t: 'question', q: entry.number, i }),
|
|
1101
|
-
unsupport_tips: '请升级 QQ 客户端后使用按钮',
|
|
1102
|
-
},
|
|
1103
|
-
group_id: `q-${entry.number}`,
|
|
1104
|
-
}
|
|
1105
|
-
})
|
|
1106
|
-
const keyboardRows = []
|
|
1107
|
-
for (let i = 0; i < buttons.length; i += 2) {
|
|
1108
|
-
keyboardRows.push({ buttons: buttons.slice(i, i + 2) })
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
// 目标只来自本提问所属会话(peer);绝不回退 this._lastPeer——
|
|
1112
|
-
// 那会把卡片打到上一个会话,甚至把单聊 peer 当成群 openid 打 /v2/groups/(C 项实测)
|
|
1113
|
-
const targetPeerId = peer?.peerId
|
|
1114
|
-
if (!targetPeerId) {
|
|
1115
|
-
this.logger?.warn?.('[dsh-bridge qq] 提问卡片缺少会话目标,改为文字回退')
|
|
1116
|
-
return false
|
|
1117
|
-
}
|
|
1118
|
-
const scope = peer?.scope ?? (peer?.isGroup ? 'group' : 'c2c')
|
|
1119
|
-
try {
|
|
1120
|
-
const res = await this.gateway.sendMarkdown(targetPeerId, rows.join('\n'), {
|
|
1121
|
-
scope,
|
|
1122
|
-
msgId: peer?.msgId ?? peer?.messageId,
|
|
1123
|
-
keyboard: { content: { rows: keyboardRows } },
|
|
1124
|
-
})
|
|
1125
|
-
return res?.success !== false
|
|
1126
|
-
} catch (err) {
|
|
1127
|
-
this.logger?.warn?.('[dsh-bridge qq] 提问卡片发送失败(回退文字):%s', err?.message ?? err)
|
|
1128
|
-
return false
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
dispose() {
|
|
1133
|
-
// 正常关停路径:若有未收尾的断流正文,尽力补发一次(不阻塞 dispose)
|
|
1134
|
-
if (this._turnText) void this._finishTurnStream()
|
|
1135
|
-
this._inTurn = false
|
|
1136
|
-
this._turnText = ''
|
|
1137
|
-
this._turnPeerKey = ''
|
|
1138
|
-
this._activityLine = ''
|
|
1139
|
-
this._lastPeer = null
|
|
1140
|
-
super.dispose()
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
// 导出,便于测试与复用
|
|
1145
|
-
export const qqNodeHelpers = {
|
|
1146
|
-
splitForQQ: conversationBridgeHelpers.splitForIM,
|
|
1147
|
-
digestLine: conversationBridgeHelpers.digestLine,
|
|
1148
|
-
textOfAssistantMessage: conversationBridgeHelpers.textOfAssistantMessage,
|
|
1149
|
-
listSessions: conversationBridgeHelpers.listSessions,
|
|
1150
|
-
sessionsInDisplayOrder: conversationBridgeHelpers.sessionsInDisplayOrder,
|
|
1151
|
-
sanitizeQQMarkdown,
|
|
1152
|
-
isBlockedDownloadUrl,
|
|
1153
|
-
}
|