Commit Graph
1191 Commits
Author SHA1 Message Date
DrSmoothl 3e99d3bb5a fix(webui): guard async polling against re-entrant overlap
三个 setInterval 轮询(列表 reconcile、systemInfo 快刷、6h 更新检查)调度的都是 async
tick,但 setInterval 不感知 Promise 完成——当某次请求慢于轮询间隔时,下一次 tick 会在
上一次未完成时又发起,导致同类请求并发堆叠、越积越慢。

每个 tick 加在途 running 互斥:上一次刷新还在飞就跳过本次,完成(try/finally,含出错)后
恢复调度。保留已有的 document.hidden 门控与变可见补刷。慢 tick 只是被跳过、完成后自愈,
不会永久停摆。

(引 non-overlapping-poll 工具 + 给 webui 新增测试基建对 3 个轮询点属过度工程,故就地加
互斥。)
2026-07-02 21:04:35 +08:00
DrSmoothl b5ede95772 test(core): fake timers for group-history walk-back to skip send-gap waits
The walk-back loop enforces a real ~300ms gap between each SsoGetGroupMsg send
(a deliberate process-wide anti-flood throttle). The test paid that gap for
real — up to 300ms x 12 sends per case — making the file ~5.2s.

Install vitest fake timers and drain the queued send-gap timers via
runAllTimersAsync, so the loop/cursor logic runs instantly. The production
throttle is untouched (only the test file changes).

Walk-back test body ~5237ms -> ~4ms; full core suite ~21s -> ~4.4s.
2026-07-02 19:55:50 +08:00
DrSmoothl 1f2bf4d3f2 chore(deps): patch low-severity esbuild + @babel/core advisories
两个构建/测试期工具链的 transitive 依赖存在低危「任意文件读」告警(非运行时产物):
- esbuild 0.27.7 → 0.28.1(dev server 任意文件读,vite 接受 ^0.28.0,兼容)
- @babel/core 7.29.0 → 7.29.7(sourceMappingURL 任意文件读)

pnpm.overrides 钉住修复版下限(esbuild@<0.28.1 / @babel/core@<7.29.6),防止后续
lockfile 重生回退。全 12 包 typecheck 干净、webui vite build 通过、protocol 572 测试
全绿,工具链未受影响。
2026-07-02 15:16:40 +08:00
DrSmoothl c85c510c0b perf(webui): trim debug-view rendering cost (json-tree, activity, tester)
- json-tree:折叠节点不再枚举子项(改用 count),整棵树的 JSON.stringify 从每次渲染
  改为点击复制时才算。
- live-activity:搜索命中串在入队时预生成,过滤不再对每行 JSON.stringify(300 行 ×
  每次击键)。
- action-tester:流式帧封顶 2000,长/失控流不再无限增长数组与 DOM。

均行为保持(或纯改善),对抗性 Agent 逐条核验无回归。
2026-07-02 15:02:13 +08:00
DrSmoothl 99547e847e perf(webui): gate list/system polling on tab visibility
两个兜底轮询(列表 reconcile、systemInfo 快轮询)的 tick 在 document.hidden 时跳过,
并监听 visibilitychange 在切回时立即补一次。后台标签页不再空转 fetch;实时状态本就
走 SSE(未门控),切回时立即刷新无空态闪烁。
2026-07-02 15:02:13 +08:00
DrSmoothl d17381a221 perf(webui): batch log SSE into ~80ms flushes instead of per-line setState
日志页每收到一条日志就 setLogs(prev => [...prev.filter(id≠), entry].slice(-cap)),
高日志速率下是每条一次 O(n) 过滤+复制 + 一次 React commit。改为入队到 pending、
每 ~80ms 批量 flush:一次 O(n) 合并(Map 去重、last wins)+ 一次 commit。去重(重连
重放同 id)与 maxLines 上限语义保持不变,卸载时 dispose + 清 timer。
2026-07-02 15:02:13 +08:00
DrSmoothl 72aedbc612 chore(release): v1.11.1 2026-07-01 22:15:01 +08:00
DrSmoothl 63ab7a1846 fix(protocol): parse Qzone feeds without eval + stop getCookies hanging (#182)
真机抓包定位到 get_qzone_feeds 的两个问题:

1) feeds3_html_more 的响应体是**JS 对象字面量**(键无引号、单引号字符串、\xNN
   转义、数组含 undefined),不是 JSON → JSON.parse 报 "Expected property name
   at position 66"(报告者看到的「响应结构问题」)。实测调参数(outputhtmlfeed/
   format)改不掉这个格式,且 feeds 是纯远程 H5(native i64 里无相关 CGI),没有
   干净 JSON 端点。改用**非执行的递归下降解析器** parseJsLiteral 把它当数据解析——
   绝不 eval 远程内容(vm 沙箱不是安全边界,被篡改的响应可逃逸到 RCE)。解析器只认
   对象/数组/字符串/数字/true|false|null|undefined,丢弃 __proto__ 键防原型污染;
   mapFeeds 过滤数组尾部的 undefined 洞。

2) getCookies 卡死:HttpsGetCookies 无超时、无深度上限地递归跟随 302;当某跳目标
   不可达(数据中心/Docker 上 qzone.qq.com 裸域超时)→ 永久挂死所有 cookie-backed
   web 动作。加 8s 超时 + 深度上限(5) + 失败/超时保留已收集 cookie(不再 reject)。

两处修复均在 tempserver 真机验证:getCookies 从卡死 3 分钟变正常返回;抓到的真实
feeds 响应体做成测试 fixture,非执行解析器正确解出 uin/nickname/html(\x3C→<)。
读接口加了 TRACE 原始体日志 + 结构失败错误带响应片段,方便后续排查。
(msglist 的 -10000「使用人数过多」判定为 taotao 服务端风控——同 cookie 下 feeds
能成功、msglist 单独挂,非代码 bug,代码已正确透传。)

protocol 572 全绿、typecheck 干净;两轮对抗性 Agent:先揪出 vm 版可 RCE 逼我换非执行
解析器,再确认新解析器构造上不执行代码、无原型污染、真机数据解析正确。
2026-07-01 21:56:28 +08:00
DrSmoothl 3932958c39 fix(bridge,common,onebot): reject phantom hook UINs + never crash logging (#162)
注入的竞态窗口里,native hook 偶发上报 13 位垃圾 UIN(时间戳型,读到 QQ 尚未落定
的内存)。TS 层防御缺失,导致两个连锁问题:

1) 幽灵账号:isRealUin 只查「纯数字且 ≥5 位」、无上限,垃圾值通过后建会话并把
   config/onebot_<垃圾>.json 落盘 → OneBot 节点配置里出现随机幽灵账号(跨重启残留)。
2) 进程崩溃:logger 彩色路径 ' '.repeat(SLOT - tagLen),UIN tag(含方括号)超宽 →
   负数 → RangeError: Invalid count value → uncaughtException。

跨平台(Win DLL / Linux .so 同逻辑)+「更新 QQ 后消失」都印证是 native 读到那块内存
的偶发垃圾;根修在 SnowLumaNative,此处补齐 TS 防御:

- 抽出唯一的 @snowluma/common/uin::isRealUin = /^\d{5,10}$/(真实 QQ ≤ uint32 →
  10 位)。删除 bridge/core 两处重复副本(重复正是这次漏加上限的温床)。等价旧逻辑,
  仅新增拒 11+ 位。
- logger.ts 加 Math.max(0, …),日志永不崩进程(独立健壮化)。
- 收紧 WebUI UIN_REGEX /^\d{5,12}$/ → /^\d{5,10}$/ 保持一致。
- 启动时 cleanupInvalidPerUinConfigs() 清掉已落盘的幽灵 onebot_<uin>.json(只删
  UIN 不合法的,绝不碰合法账号/全局配置)。

测试:common +8(uin 6 / logger 2)、onebot +3(cleanup)。typecheck 12 包干净;
common 58 / onebot 519 / core 471 / bridge 73 全绿。对抗性 Agent 逐输入核验等价性、
三层 gate 堵死幽灵入口、cleanup 安全时序,无回归。
2026-07-01 20:37:02 +08:00
DrSmoothl ade76d3d2f refactor(protocol): reuse tested Sha1Stream in video block-sha1 (drop dup)
video-upload.ts 有一份 ~110 行手写、**无测试**的流式 SHA1(Sha1StreamState),与
sha1-stream.ts 里已充分测试的 Sha1Stream 逐字节相同(transform 数学等价、中间 state
小端)。删掉该重复,computeVideoSha1Blocks 改用 Sha1Stream 做 1MB 块的中间 state、
Node crypto 做整文件 final(与受测的 computeSha1StateV 同一套路;已实测旧 final() ===
Node crypto 整文件 sha1)。

- 新增 video-sha1-blocks.test.ts(4 例):golden 值从**旧实现**抓取,锁定重写 byte-
  identical(两个 1MB 中间 state + overall),并给这个原本无测试的函数永久覆盖。
- 删掉的 tail-update 只影响旧自实现 final,不动任何中间块(tail<64B 不触发 1MB 边界)。

protocol 565 全绿、typecheck 干净;对抗性 Agent 独立复现旧实现确认 golden 逐字节
相同、transform 数学等价无精度问题、无残留引用。
2026-07-01 20:14:55 +08:00
DrSmoothl c822c5dab6 refactor(webui): extract useFlashMessage hook for settings panels
三个 settings 面板各手写同一套 flash-message 状态机(msg state + setTimeout 自动
消失 + 卸载清 timer)。global-config / notifications 是完整版(逐字相同);
system-panel 是退化版(无 timer ref、无 cleanup、rapid flash 累积 timer、卸载后可能
stale setMsg)。抽 useFlashMessage(durationMs=2400) → { msg, flash, setMsg }。

- global-config / notifications:迁移后行为不变(2400ms;flash 瞬时 + setMsg 持久
  错误保留)。
- system-panel:保留 4000ms(useFlashMessage(4000)),顺带补上 prior-timer 取消 +
  卸载 cleanup(修 timer 泄漏,现代 React 里无害的小改善)。

webui typecheck(tsc -b) + vite build 干净;对抗性 Agent 逐行核对确认 hook 等价、
两面板行为不变、无依赖/import 回归。
2026-07-01 20:03:58 +08:00
DrSmoothl a8f24b8f13 refactor(onebot): converge self-sent message tail into finalizeSend
send/forward 路径里「记录自发消息」的尾巴(hashMessageIdInt32 + cacheMessageMeta +
cacheSelfSentMessage)逐字复制 6 份(sendPrivate/sendGroup/两个 forward/forwardSingle
的 group+private 分支),每处独立手工配对 isGroup ↔ eventName ↔ hash 事件常量——
漏改一处会静默产生错 message_id / 错 event 归属。抽 finalizeSend(ref, isGroup,
targetId, receipt, elements),由 isGroup 单点派生 eventName + 常量。

6 处塌成 6 行调用;forwardSingle 的 group/private if/else 只剩 send API 本身不同。
receipt 用结构类型覆盖所有调用点(含 line 630 合成的 fallback)。

行为严格不变:onebot 516 全绿、typecheck 干净;对抗性 Agent 逐字对比 HEAD 确认
6 处等价、参数/返回正确。
2026-07-01 19:55:21 +08:00
DrSmoothl f111ba7129 fix(core): resolve self uid instead of shipping an empty one (forward/flash)
阶段 2:修 self-uid 收口暴露的 2 处静默空串 bug。warmup 未完成时 identity.selfUid
为 null,原 `?? ''` 会把空 uid 放进出网包:

- forward.ts:154 responseHead.toUid(转发广播消息坏包)
- flash-transfer.ts:279 fileset uploader.uid(空 uid 会被服务端 drop fileset)

均改为 `await resolveSelfUid(...)`:正常路径(selfUid 有值,几乎总是)命中缓存、取值
完全不变;仅在身份未就绪的极端情况从「静默发坏包」变成「明确报错」(fail-loud)。
两处外层函数本就 async。group-admin.ts:64 的 ?? '' 是合成本地事件、best-effort、
非出网,保留不动。

core 471 全绿、typecheck 干净;对抗性 Agent 确认正常路径不变、无测试回归。
2026-07-01 19:18:10 +08:00
DrSmoothl 3db89d0bd0 refactor(protocol,core): converge self-uid resolution to one impl
「取自己 uid」原本散成 5 份、3 种错误文案:core/apis/shared.ts::resolveSelfUid、
group-file/message 各内联手抄一份、get-like 跨包再抄一份。收敛成 protocol 单一
resolveSelfUid(ctx: Pick<BridgeContext,'identity'|'resolveUserUid'>)。

阶段 1(行为不变):
- 新增 protocol/self-uid.ts(依赖方向 core→protocol 要求权威实现下沉 protocol;
  Bridge 与 OIDB service Deps 都满足 Pick 切片)。
- core/apis/shared.ts 改为 re-export 它(现有 resolveSelfUid(bridge) 调用点不变)。
- get-like/group-file/message 三处内联手抄 → 全调权威实现。
- 唯一副作用:message 的错误文案统一为 'self uid is unavailable'(无测试依赖旧文案)。

flash-transfer/forward 的 ?? '' 空串 bug 留待阶段 2;group-admin 合成本地事件保持不动。
全仓 12 包 typecheck Done、protocol/core(471)/onebot(516) 全绿;对抗性 Agent 确认
toInt/Number 边界无实际差异(identity.uin 恒为正整数串)、5 处等价。
2026-07-01 19:12:05 +08:00
DrSmoothl 298d174640 refactor(webui): extract openSseChannel for the three EventSource openers
stateStream / openLogStream / openDebugStream 三个 EventSource opener 近乎逐字重复
(token 守卫 → EventSource → onopen/onerror → onmessage try-parse-catch → close
disposer),token/reconnect/畸形帧-跳过 语义各写一遍、易漂。

- 新增私有 openSseChannel<T>(path, onMessage, onStatus?) 收敛骨架;各 opener 塌成
  一行调用,consumer 逻辑作为 onMessage 传入(保持「回调在 try 内、抛错当畸形帧
  吞掉」语义)。logs 的控制帧过滤(`'type' in parsed`)留在其 onMessage 回调里。
- StreamStatus 提到顶部 import,删掉 openDebugStream 的 inline import。

行为严格不变、对外签名不变:webui typecheck(tsc -b) + vite build 干净;对抗性 Agent
逐字对比 HEAD 确认三 opener 等价、onMessage-在-try-内 语义保留、无漏并入。
2026-07-01 18:58:22 +08:00
DrSmoothl 29a792cfdf refactor(core): extract sseResponse helper for the three WebUI SSE endpoints
/api/debug/stream、/api/state/stream、/api/logs/stream 各自逐字复制同一套 ~40 行
SSE 生命周期骨架(TextEncoder + closed 守卫 + 15s heartbeat + teardown + safe
enqueue + abort listener + event-stream headers);改一处 heartbeat 间隔/teardown
顺序/headers 要同步改三处,漏一处会 listener 泄漏。

- 新增 webui/sse-response.ts:sseResponse(c, start) 封装传输壳,给 start 回调一个
  channel { raw, encode, send, onClose, isClosed, desiredSize }。各端点特有的订阅/
  背压 framePusher/初始快照留在回调里。
- 三端点收敛:debug/state 用 createFramePusher(背压丢帧) + onClose 注册订阅;logs
  直接 ch.send;state 的 503 守卫留在 helper 外。
- 新增 sse-response.test.ts(4 例)为这三个原本无生命周期测试的端点建立安全网。

行为严格不变:core 471 全绿、typecheck 干净;对抗性 Agent 逐字对比 HEAD 确认
helper 骨架与三端点等价、无时序/守卫回归。
2026-07-01 18:53:34 +08:00
DrSmoothl c1d27c5055 refactor(protocol): converge packet-pipeline dispatch tail into finishDispatch
process() 的 sync 分支、dispatchAfterIdentityRefresh、dispatchGroupInvite 三处逐字
重复同一段收尾(handleSideEffects → printEvent → emit);再加一种需 pre-dispatch
富化的入站事件就得原样抄这三行,漏一行会静默丢日志或丢事件发射。抽 finishDispatch
收敛为一处。

- finishDispatch(event, alreadyRefreshed=false):默认 false 使 sync/group-invite
  两路的 finishDispatch(event) 等价于原 handleSideEffects(event);identity-refresh
  路透传 refreshed。与 CONTEXT 里已做的 reconcilePipeUp/Down 收敛同构。

行为严格不变:protocol 561 + core 467 全绿、typecheck 干净;对抗性 Agent 逐字对比
HEAD 确认三处等价、透传正确、三条路径均有测试覆盖。
2026-07-01 18:43:20 +08:00
DrSmoothl 85ca207279 refactor(onebot): extract openSqliteDb helper for the three stores
message/media/reaction store 构造器各自逐字复制同一段引导(mkdir 父目录 + 打开
DatabaseSync + PRAGMA journal_mode=WAL + PRAGMA synchronous=NORMAL),改一处 PRAGMA
要改三处。抽 openSqliteDb(dbPath) 收敛,schema init 与路径改写留在各调用方。

- message-store 的 .json→.db 改写保留(改写后同目录,mkdir 目标不变,等价)。
- 三文件删掉不再使用的 fs/path import。

行为严格不变:onebot 516 全绿、typecheck 干净;对抗性 Agent 逐字对比 HEAD 确认
三处引导等价、路径细节无边角、无回归。
2026-07-01 18:37:42 +08:00
DrSmoothl d89b095af5 refactor(protocol,proto-defs): fold 1:1 subpath exports into wildcards
protocol/package.json 有 273 行专给 91 个 oidb-service(每个 3 行 1:1);加一个
service 忘改 package.json → build 期解析失败。把所有严格 1:1 的目录组折叠成通配符
(moduleResolution:"bundler" 原生支持),消除永久手同步负担。

- protocol:./oidb-services/*(91) + ./highway/*(7) + ./web/*(7) + ./msg-push/*(3);
  bare ./msg-push / ./highway(exact)与单文件 export 原样保留。
- proto-defs:./oidb-actions/*(10)。
- 净 -467/+15 行;两包均 private:true,通配新暴露的少量内部文件无害(exports 只
  gate 跨包解析、无外部消费者、无副作用)。

零源码/import 改动。全套工具链验证:12 包 typecheck Done、protocol/onebot(516)/
core(467)/mcp(22) 测试全绿;对抗性 Agent 端到端解析 136 个真实深导入全部命中、
确认 exact/wildcard 共存正确。
2026-07-01 18:30:45 +08:00
DrSmoothl 15a6fe3785 refactor(onebot): make event converter dispatch an exhaustive registry
阶段 2(零行为改动):把 convertEvent 的 17-arm switch 换成
CONVERTERS: { [K in QQEventVariant['kind']]: ConverterFor<K> | null } 表。mapped
type 对事件类型联合 total——新增一个 kind 却漏写 converter 会**编译报错**,而非
像原 switch 那样静默走 default: return null。

- ptt_trans_result(内部语音转文字事件,不对外)在表里显式标 null,与原 default
  行为等价。
- 派发一次 localized cast 解 correlated-union,纯类型层、运行时等价。
- 护栏实测有效:删任一 kind 条目即 TS2741 编译失败。

onebot 516 全绿、typecheck 干净;对抗性 Agent 逐一核对 17 映射、独立复现护栏报错。
2026-07-01 18:03:02 +08:00
DrSmoothl ae17bc0cf5 refactor(onebot): extract event envelope builders; collapse message triplets
阶段 1(零行为改动):消掉 17 个 converter 各手搓的 time/self_id/post_type 信封,
合并 message 三胞胎。

- 新增 event-converter/envelope.ts:notice/request/message 三个信封 builder,
  converter 只供应各自 post_type 的字段。
- to-notice(12)/to-request(2) 改用 notice()/request()。
- to-message:friend+temp 合成 convertPrivateMessage(subType)(仅 sub_type 差),
  group 独立;新增 toSegments 收敛 7 参 elementsToJson splat。导出名不变。

行为 byte 级不变(测试 toEqual 顺序无关,key 集合与值逐一保持):event-converter
48 + onebot 全量 516 全绿、typecheck 干净;对抗性 Agent 逐 converter 对比 HEAD 确认
16 个输出等价、无字段增减/值变、无运行时循环。
2026-07-01 17:58:49 +08:00
DrSmoothl 891a197335 refactor(onebot): collapse action registration into a single barrel
加一个 action 文件原本要同步改三处手写清单:ApiHandler 的 12 个 register import +
12 行构造调用、各文件的 register footer、action-docs 的 13 import + GROUPS。现收敛
成一个 actions/index.ts barrel 作为「文件→动作 + 分类」的单一真相。

- action-kit:抽 RegisteredActionSpec(describe+register),ActionSpec/
  StreamActionSpec extends 它,registerActions 参数放宽到 RegisteredActionSpec[]
  (它只调 .register),于是 stream 动作也能并入统一注册。
- 新增 actions/index.ts:ACTION_GROUPS(11 组含分类,stream 合并)+ ALL_ACTIONS。
- api-handler:12 import+12 调用 → registerActions(this, context, ALL_ACTIONS);
  .handle_quick_operation(唯一非 ActionSpec 的裸 handler,需要 h 本身)从
  extended 搬进构造器一处显式注册。
- action-docs:改用 barrel 的 ACTION_GROUPS,删本地 GROUPS + 13 import。
- 删 12 个 register footer + 清理因此变 unused 的 ApiHandler/ApiActionContext import。

行为严格不变:注册的动作集合、.handle_quick_operation、文档分类/计数均一致。
onebot 516 全绿、全仓 12 包 typecheck Done;对抗性 Agent 逐条对比 HEAD 确认等价。
2026-07-01 17:49:07 +08:00
DrSmoothl b6cde5101d refactor(common,core): extract mapWithConcurrency util; use in forward enrich
阶段 2:把「限量+并发分批」的手写循环收进一个 common util。此形状已被复制进
私有 SLE(friend.ts),是已发生的复制漂移;抽出后堵住它、并给未来的富化点一个
现成原语(cap + 并发批 + best-effort 是防 Tencent 风控封号的标准配方)。

- 新增 common/concurrency.ts:mapWithConcurrency(items, concurrency, fn),批处理
  语义(每批 Promise.all 等齐再下一批,非 rolling pool,保持 forward 现有行为),
  结果按输入顺序返回。7 个单测覆盖顺序/绝对index/峰值≤limit+跨批serialize/
  不整除/单批/空/concurrency<1。
- 迁移 forward.ts residue 昵称富化的并发批循环;cap(slice MAX)/dedup(Set)/
  best-effort(fn 内 try/catch) 保留在 callsite。行为严格不变。

对抗性 Agent 逐条对比 HEAD 确认等价。common 50 / core 467 全绿、typecheck 干净。
2026-07-01 17:29:25 +08:00
DrSmoothl 9d67630396 refactor(common,core): extract single-flight+TTL cache primitive
阶段 1:把手写多份的「单飞+TTL 记忆化」收进一个 common 原语,消除各处
各自实现「TTL 判定 / inflight 去重 / finally 清理」的漂移风险(底层都是 Tencent
风控:忙碌客户端会为每条消息发一次未缓存 OIDB 拉取,持续爆发触发封号)。

- 新增 common/single-flight-cache.ts:createSingleFlightCache<K,V>({ ttlMs, load,
  shouldCache?, now? }).get(key, {force});shouldCache 容纳「错误结果不缓存以便
  重试」,now 可注入便于测 TTL。7 个单测覆盖新鲜命中/单飞去重/force/TTL 过期/
  shouldCache/错误传播/invalidate。
- 迁移 contacts.fetchGroupMemberList(key=groupId, ttl 60s, 默认 shouldCache)与
  update-check.getUpdateInfo(全局 key, ttl 6h, shouldCache=!error)。行为严格不变。

对抗性 Agent 逐条对比 HEAD 确认两处等价(含 update-check 时间戳来源毫秒级微差、
对 6h TTL 可忽略)。common 43 / core 467 全绿、typecheck 干净。
2026-07-01 17:25:04 +08:00
DrSmoothl 6f6f372337 refactor(onebot): drop 55 redundant ACTION_FAILED try/catch in extended actions
阶段 2b:从 extended.ts 删除 55 个「纯兜底」try/catch(catch 体恰为单条
return failedResponse(ACTION_FAILED, String(e)/e.message)),让抛错落到唯一 seam
runAction(已统一 100 + error.message)。retcode 不变(这些本就是 100),去的是
冗余包装。

- 保留:try/finally、带前缀的 catch(获取精华消息失败 / 语音转码失败)、
  download_file 保存失败的 BAD_REQUEST catch、fetchFilteredGroupRequests 的
  catch{return []} 吞值——均原样未动。
- 唯一文案变化:约 30 个原用 String(e) 的 catch,其 wording 由 "Error: X" 变为
  seam 的裸 "X"(丢类名前缀,retcode 不变;OidbError 的服务器码仍在 message 内)
  ——一致化改善,无测试/契约依赖该前缀。
- onebot 全量 516 全绿、typecheck 干净;对抗性 Agent 逐块比对 HEAD 确认等价、
  结构正确、无误删漏改。
2026-07-01 16:30:00 +08:00
DrSmoothl 5be852f394 refactor(onebot): drop redundant per-action try/catch in qzone/album/admin
阶段 2a:删除 qzone(7)、group-album(8)、group-admin(1) 共 16 个「纯兜底再包一层」
的 try/catch,让抛错自然落到唯一 seam runAction(阶段 1 已统一为 ACTION_FAILED 100
+ error.message)。qzone/album 原返回 1200 → 归一 100(本任务有意);group-admin
原本就是 100,仅去冗余包装。

- 保留:qzone helper uploadQzoneImages 的带前缀 re-throw catch、group-info 的
  honor-info 前缀 catch、message 的吞值 catch(best-effort 保留旧 URL)——均未动。
- 清理三文件不再使用的 RETCODE/failedResponse import。
- qzone-actions 测试断言 1200 → 100(wording 前缀经 seam 仍保留)。
- onebot 全量 516 全绿、typecheck 干净;对抗性 Agent 逐个对比 HEAD 确认纯兜底、
  无其它漂移、无误删漏改。
2026-07-01 16:20:26 +08:00
DrSmoothl ab45667fdf refactor(onebot): unify action-error retcode at the runAction seam
将「动作抛错返回什么错误码」从各处不一致(100 / 1200 / 漏包→1200)收敛到唯一
兜底处 ApiHandler.runAction,统一为 ACTION_FAILED(100) + error.message。

阶段 1(改 seam 本身):
- runAction catch:默认码 1200 → 100;文案 'internal error' 兜底改为 String(error)。
  OidbError.message 本就含 QQ 服务端 code,无需特判。
- 新增 3 个测试锁定策略:抛 Error→100+message;抛 OidbError→100 且 wording 含
  code+server msg;抛非 Error→100+String()。
- rename_group_file 那个原断言 1200 的测试(未包裹动作、落 runAction)改为 100。

各 action 里多余的 try/catch(qzone/group-album 等仍返回 1200)留待后续阶段删除,
在此之前其失败不经此 seam。onebot 全量 516 全绿、typecheck 干净。
2026-07-01 16:11:49 +08:00
DrSmoothl ab40f086ee refactor(protocol): dedup NTV2 record/video mediaNode builder in decoder
阶段 3(零行为改动):record(bizType 12/22) 与 video(bizType 11/21) 解码分支里
各写一份、逐字完全相同的 23 行 `mediaNode:{ fileUuid,storeId,uploadTime,ttl,
subType,info:{…} }` 块,抽成共享 helper buildMediaNode(idx, fi),消除这对并行拷贝
的漂移风险(改一处忘另一处)。

- VideoFile / Ptt 两处的 mediaNode 来源变量与 shape 不同,正确保持独立、未误并。
- 逐字提取,行为 byte 级不变:typecheck 干净、protocol 全量 561 全绿;对抗性
  Agent byte-diff 确认原两份完全相同、helper body 与之逐字一致。

注:计划中的 poke faceId→subType 字段对齐已主动撤销——poke 不能当消息段发送,
且自身消息回显路径会读 element.subType 使该改动可观测,违反"重构行为不变"。
2026-07-01 15:56:43 +08:00
DrSmoothl 8e173cf7d7 refactor(onebot): merge element toSegment/fromSegment into one codec table
阶段 2(零行为改动):把「同一种消息元素」的两个 onebot 方向——收·转
(to-segment) 与 发·解(message-parser)——从两个文件各写各的分支,合并到一张
按元素类型索引的 ELEMENT_CODECS 表,每种类型一条 { toSegment?, fromSegment? }。

- 新增 event-converter/element-codecs.ts:13 类 toSegment + 14 类 fromSegment
  逐字平移自原实现;私有 helper intOr/marketFaceElement/pickMediaSource 移来并导出。
- to-segment.ts:elementToSegment 改查表 + 保留原 default 透传兜底。
- message-parser.ts:segmentToElement 保留 9 个纯输入糖 case 作前置 normalize,
  真实类型改查表,末尾保留原 unsupported warn。
- onebot 对账测试升级为按表键核对 S/P(比正则更硬),并断言真实类型 case 不再
  残留于 parser、输入糖仍在。

行为 byte 级不变:onebot 全量 513 全绿、typecheck 干净;对抗性 Agent 逐字比对
确认无漂移、无运行时循环依赖。
2026-07-01 15:48:04 +08:00
DrSmoothl f4170b014a refactor(protocol,onebot): add message-element four-direction reconciliation manifest
消息元素散在 4 处派发(收·解 / 收·转 / 发·解 / 发·打包),历史上各写各的、
无对账,导致字段名对不上、漏写方向走 default 静默失败。

阶段 1(零行为改动):立一份权威清单 element-manifest + 两道架构护栏测试。
- element-manifest.ts:每种元素类型 × 四向支持('yes'/'no'/'by-design-no')
  + 字段契约 + typesForDirection() + 输入糖白名单。清单逐字反映当前行为。
- 两道对账测试(因包边界拆两处、共用清单):正则扫 4 处派发代码,比对清单。
  谁增删一个方向却不同步清单,测试当场报红,不再运行时静默。
- 取值域用 [A-Za-z0-9_]+,避免未来驼峰/下划线类型名被静默漏抓致护栏失效。
- poke 的 W=by-design-no:QQ 不允许戳一戳当消息段发送,缺席正确。

对抗性 Agent 两轮复审通过。
2026-07-01 15:48:04 +08:00
囧囧JOJO f424454422 fix(webui): align debug account picker styling (#181) 2026-07-01 14:43:29 +08:00
DrSmoothl 5d6ce86b66 refactor(onebot): give trans_group_file semantic param roles
Follow-up to #161: trans_group_file's group_id/file_id used plain
f.uint()/f.string(); switch them to f.groupId()/f.fileId() (identical
coercion) so the WebUI debug console renders a group picker + file-id
control for the new action and the catalog carries the roles. Catalog
regenerated.
2026-07-01 02:27:35 +08:00
Airline233 9ca64bed10 feat(core,onebot): implement trans_group_file action (#161) 2026-07-01 02:13:35 +08:00
Airline233 2f374ea8c9 feat(qzone): support post visibility rights when publishing qzone posts (#178)
Add ugc_right and target_uins to the qzone publish path across protocol,
bridge, and the send_qzone_msg OneBot action, regenerate the MCP catalog,
and cover restricted visibility (16/128) with validation tests.
2026-06-30 23:57:39 +08:00
DrSmoothl c90a3954e4 fix(bridge): self-heal login detection when the hook misses the edge
Docker SnowLuma could hang forever at "等待登录" after QQ had already
logged in. Root cause: login detection is edge-triggered — it only learns
the state from a loginState frame the native hook PUSHES when it observes
the login transition. `getLoginState()` is a cached value read once at
connect; after that the session waits passively. Desktop attaches the
hook before the user logs in, so it catches the edge. In Docker, QQ
auto-logs-in from a persisted session — fast on startup — so the
transition lands before the recv pipe is connected (frames aren't
replayed) or via a silent restore path the hook doesn't intercept. The
edge is missed and the cached state stays not-logged-in.

Fix: while connected but not logged in, actively re-probe QQ's own
ptlogin/deeplink ports via probeQqLoginInfo (level-triggered, pipe-
independent — the same call the workaround used) and synthesize the login
once it reports a real uin. Immediate probe on connect + a 3s interval;
stopped on login / teardown / dispose, with an in-flight guard so a slow
port scan can't stack concurrent probes. Idempotent with the pushed-frame
path. 7 tests cover detect-via-probe, the later-interval case, the
not-logged-in / non-real-uin / throwing-probe guards, no double-emit, and
no concurrent stacking. probeLogin is injectable for tests.
2026-06-30 23:29:51 +08:00
DrSmoothl 397087dc74 test(core): hoist Bridge import to fix flaky 5s timeout
bridge-private-routing.test.ts loaded the Bridge dependency tree via
`await import(...)` INSIDE each `it`, so the first test paid the whole
~3.8s module-eval cost against its 5000ms per-test budget — fine locally
but tipping over on slower CI runners (surfaced as a timeout on an
unrelated PR). Moved the Bridge / IdentityService imports to top-level
statics (vi.mock is hoisted above them, so element-builder stays mocked):
the heavy load now happens once at file collection, not against a test
timeout. Per-test execution dropped from ~3.8s to ~7ms.
2026-06-30 23:09:21 +08:00
DrSmoothl 48e2777bc3 chore(release): v1.11.0 2026-06-30 20:38:37 +08:00
DrSmoothl a8307891fa fix(core): backfill forward-msg sender nickname/card (#174)
get_forward_msg nodes came back with empty or "QQ用户" sender names: the
SsoRecvLongMsg payload doesn't always populate the member name, and the
sync decode only consulted the L1 identity cache. So a sender absent from
that cache (just-started bot, inactive member) had no name.

ForwardApi.enrichSenders() now backfills nameless nodes on both fetch
paths (wire + cache-hit, with write-back so seeded inner caches enrich on
first read):
- L3: one fetchGroupMemberList per distinct group (inflight+TTL cached)
  resolves every in-group sender at once; card preferred over nickname,
  matching the existing wire-decode convention.
- L4: per-uin fetchUserProfile only for the residue (members who left the
  group, private nodes).

Best-effort: a failed lookup keeps the placeholder, never failing fetch.

Adversarially reviewed — fixed the one High it found: the L4 fallback was
an unbounded Promise.all of un-cached OIDB 0xFE1_2 lookups, which a
high-churn group's historical forward (many since-left senders) or a
flaky member-list call could amplify into a rate-limit ban (the very risk
ContactsApi's own cache note warns about). Now: member-list failure keeps
the placeholder instead of fanning out, and the residue is capped
(FORWARD_PROFILE_MAX) and resolved in small concurrency-limited batches.
2026-06-30 20:32:49 +08:00
DrSmoothl b6c476390f ci: auto-close non-template issues from non-write-access users
blank_issues_enabled: false only hides the chooser link — the CLI / API /
direct URLs still create template-less issues (e.g. #174 by a CONTRIBUTOR).
This workflow enforces the policy: an issue opened by someone without write
access that carries no template label (bug / enhancement) is commented on and
closed as not-planned. Proper bug reports / feature requests and write-access
users are unaffected.

Takes effect only once on the default branch (main).
2026-06-30 20:16:59 +08:00
DrSmoothl fc4d3a9c5d fix(protocol): set_group_name body tag 3, not 1 (#173, OIDB 1006)
set_group_name (OIDB 0x89a_15) always failed with OIDB error 1006 even
with admin rights. The group name in the body sits at tag 3, but we had
it at tag 1 — so the server rejected the malformed request structure
before ever checking permission. Confirmed against Lagrange's
OidbSvcTrpcTcp0x89A_15Body (ProtoMember(3) TargetName).

The reporter had tried tag 1 and tag 2 (both still 1006) — the answer was
tag 3. Live-verified on tempserver: with the fix the error changes from
1006 (structure rejected) to 1328 (permission — the test bot is only a
member), proving the request structure is now accepted; an admin bot will
succeed.
2026-06-30 20:14:20 +08:00
DrSmoothl 1f23e6e9f0 fix(protocol): ptt-trans md5 must be a hex string, not raw bytes (#165)
fetch_ptt_text failed for every voice with QQ errCode -1079 ("ptt
translate failed"). RE'd the cause in wrapper.linux.node: EncodeTroopPtt /
EncodeC2CPtt (ptt_trans_worker.cc) run the md5 through a bytes→hex helper
(sub_7A02D80, the 000102…ff table) before putting it in the request — so
the md5 field is a 32-char lowercase HEX STRING, but we were sending the
raw 16 bytes (hexToBytes(md5Hex)). Wrong length + encoding → server reject.

All other fields matched QQ NT exactly (verified field-by-field against
the two encoders): group {1=msg,2=sender,3=group,4=fileId,5=md5,6=dur,
7=size,8=format,9=uuid,10=event}, c2c {…,9=md5}, type 1=group/2=c2c.

Fix: md5 field is `string` (the lowercased hex), not `bytes`. Live-
verified on tempserver — errCode -1079 is gone and a real voice now
returns ASR text. (The "Live-verified end to end" claim in the old
comment was wrong; c2c had the same latent bug.)
2026-06-30 20:02:07 +08:00
DrSmoothl 0cf9cdd0d7 chore(core): diagnose ptt translate failures (#165 备选)
When QQ's ASR returns a non-zero errCode, log the request fields we
sourced from cache (md5 length / format / uuid presence / duration /
size) alongside the errCode, so a server-side ASR failure can be told
apart from our own missing/zero field. Behaviour unchanged — still
throws. Helps diagnose the "ptt translate failed" instability reported
in #165 before deciding whether it's our bug.
2026-06-30 19:31:20 +08:00
DrSmoothl 90456def2e feat(onebot): get_record out_format — server-side voice transcode (#165)
#165: get_record gains an optional `out_format` (mp3/amr/wma/m4a/spx/ogg/
wav/flac, mirroring NapCat). When set, the QQ voice (SILK/AMR) is
transcoded server-side and the result is attached as `data.base64`; the
original info (file/url/file_size) is preserved. Absent ⇒ behaviour
unchanged. This spares ASR consumers from shipping a SILK decoder.

- protocol: convertAudioBytes(bytes, format, deps?) drives the bundled
  SILK-capable ffmpeg addon's convertFile through temp files (always
  cleaned up), with a format allowlist and an output-size cap. Pure/
  injectable, unit-tested with a fake addon (6 cases).
- core: ExtrasApi.convertRecord(source, format) = loadBinarySource (64 MiB
  cap) → convertAudioBytes.
- onebot: get_record wires out_format; transcode failure (incl. an addon
  lacking SILK decode) degrades to ACTION_FAILED, never crashes.

Adversarially reviewed: no path traversal (format allowlisted), no SSRF
(source is the cached CDN url, not user input), cleanup covers every path,
graceful degradation confirmed. Catalog regenerated. Real-machine e2e
(does the shipped .node actually decode SILK?) is Phase 2.
2026-06-30 19:25:40 +08:00
DrSmoothl 8fc5917b3b fix(webui): align message composer/builder (Apple-HIG)
Two alignment fixes in the 消息构建 tab:
- MessageBuilder is now a bounded "compose canvas" (always bordered) so the
  empty hint reads as an intentional empty state inside a defined input
  region, instead of a stray centered line floating in the left-aligned
  form.
- The 账号 / 目标 label rows are pinned to a fixed h-8 (== the 群聊/私聊
  segmented control's height) so both labels and the selectors beneath them
  share a baseline — min-h-7 wasn't enough since the segmented is ~32px.
2026-06-30 18:57:54 +08:00
DrSmoothl 65195abbe0 fix(mcp): add role/stream to Catalog types to match generated catalog
Phase 0 added `role` to FieldDoc and `stream` to ActionDoc and regenerated
catalog.ts, but the mcp package's own CatalogParam/CatalogAction types
(intentionally decoupled from onebot per ADR-0005) weren't updated — so
`tsc --noEmit` failed on the generated catalog.ts (TS2353, unknown
properties role/stream). The mcp vitest suite passed because esbuild
doesn't type-check the generated snapshot, which is why this slipped.

CatalogParam gains role?: string (loose, to stay decoupled from onebot's
FieldRole union); CatalogAction gains stream?: boolean. `pnpm typecheck`
is green across all packages.
2026-06-30 18:45:34 +08:00
DrSmoothl d5fbd6fa06 feat(webui): multi-tab debug console + embedded API browser
Phase 6 of the debug-console expansion — the four pieces become one
console: 测试台 / 消息构建 / 实时活动 / API 浏览器, behind an Apple-HIG
sliding-indicator tab bar.

- ApiBrowser: a searchable, category-grouped reference for every
  declarative action (~520), straight from /api/debug/actions — params /
  returns / invariants / role + readOnly + stream badges. Each row's 试一下
  prefills the tester and switches to it (ActionTester gains a
  presetAction prop keyed by nonce).
- Tester / compose / activity stay MOUNTED and toggle via CSS, so the
  live feed's SSE never drops — it keeps capturing events fired while you
  invoke from the tester tab (the invoke-then-observe loop) — and
  in-progress params/messages survive a peek at another tab. The API
  browser, heavy and stateless, mounts on demand.

Adversarially reviewed: no crash / leak / mis-trigger; the review caught
that conditional-mounting silently dropped cross-tab live events and
discarded in-progress work — fixed by keeping the stateful panels mounted.
2026-06-30 17:40:04 +08:00
DrSmoothl 383ddb0a5d feat(webui): visual message builder + composer (nested forward)
Phase 5 of the debug-console expansion. A drag-reorderable segment
builder so you never hand-write a OneBot message array: common segments
first-class (text / @ / image / face / reply / record / video), advanced
folded (file / mface / poke / json / xml / markdown / forward-node), with
`node` nesting a full builder (merged forward, depth-capped). @ uses the
group member picker via the sibling group context; media via FileSource.
Each segment carries a stable `_id` key so editing a field never remounts
the row.

- MessageBuilder + toOneBot() serialise to the exact OneBot v11 shapes
  SnowLuma's parser reads (verified field-by-field).
- MessageComposer (the 消息构建 tab): pick target, build, preview, send —
  routing through send_*_forward_msg when the message has nodes, else
  send_*_msg. Refuses a mixed node/non-node forward (the backend would
  silently drop the stray segments) and validates incomplete segments
  before sending.
- ParamField renders the builder for message-type params in the tester
  (raw-text escape hatch kept), emitting the serialized array as the
  string value.

Adversarially reviewed: serialization contract and React key/focus both
verified clean (no _id leakage); fixed the mixed-forward silent-drop,
added pre-send segment validation, and made the drag boundary explicit.
2026-06-30 17:31:51 +08:00
DrSmoothl 11866caa2f feat(webui): role-driven action tester with streaming + history
Phase 4 of the debug-console expansion — the tester no longer makes you
hand-write JSON. Each param renders the right smart control from its
semantic role: group/friend/member pickers (member linked to the sibling
group_id), FileSource for media, FaceGrid, datetime for timestamps,
switch for bool, select for enum — with the raw-JSON escape hatch kept.

- Read-only actions badge green, side-effecting amber; a name-pattern set
  of destructive actions (kick/ban/recall/delete/…) confirms first.
- Stream API actions invoke over /api/debug/invoke-stream and render
  frames live, tracked in the app-level task registry with progress; a
  clean success requires the terminal response frame, and a failed frame
  shows its reason (not just the {type:'error'} marker).
- Every call lands in capped localStorage history with one-click replay;
  responses render in a collapsible JsonTree.
- Extracted LiveActivity (filter / search / export) and the Segmented
  control into their own components; debug-page is now a thin host.

Adversarially reviewed: stream terminal-frame judging, member/group
linkage, and contract shapes verified; fixed the failed-stream-frame
reason being hidden, a latent required-enum controlled-select trap, stale
member value on group change, and several low UX edges.
2026-06-30 17:17:15 +08:00
DrSmoothl c5611ed622 feat(webui): debug-console shared primitives, data layer, task manager
Phase 3 of the debug-console expansion — the Apple-HIG foundation the
new tabs build on (reusing the existing ui kit; new controls match its
idiom, no browser-native widgets).

API client + types:
- debug.invokeStream() reads /api/debug/invoke-stream via fetch + a body
  reader, parsing data:<json> SSE frames (header auth, cancellable).
- debug.upload() streams a file via XHR for real upload progress.
- DebugActionParam.role, DebugActionDoc.stream/invariants, FieldRole,
  DebugStreamFrame, DebugUploadResult.

Primitives:
- Tabs (sliding indicator + roving tabindex), Picker (searchable,
  windowed combobox with a raw-value escape hatch), JsonTree (collapsible
  response viewer, image-url preview, copy w/ http fallback, node cap),
  FileSource (URL / server-path / browser-upload tri-state), FaceGrid.

Infra:
- DebugTaskProvider + TaskBadge: app-level registry of in-flight upload/
  stream tasks so they survive in-app tab switches, with a global
  progress chip and a beforeunload guard; eviction never drops a running
  task. Wired into App.
- use-debug-contacts: lazy friend/group/member data layer, shared module
  cache + localStorage (version-stamped, avatars stripped to dodge quota)
  + TTL + request-id race guard, degrading to raw input on error.

Adversarially reviewed (no automated component tests): virtualization
math and SSE framing verified correct; fixed the stale-contacts-on-switch
member-selection hazard, the quota-kills-persistence issue, and several
low UX edges (active-row clamp, filter scroll reset, copy fallback).
2026-06-30 16:53:43 +08:00
DrSmoothl a7667f1cd5 feat(core,onebot): debug-console stream-invoke + upload endpoints
Phase 2 of the debug-console expansion. Two backend endpoints under the
existing /api/* bearer auth:

- POST /api/debug/invoke-stream — drive a Stream API action (#163) and
  relay every frame to the browser over an SSE body. The frontend reads
  it with fetch()+a stream reader (header auth), so it needs no SSE
  query-token allowlist entry. Frames are NEVER dropped: backpressure is
  handled by waiting for the consumer to drain, not by discarding (a lost
  chunk corrupts a transfer). Non-stream actions emit a single frame.
- POST /api/debug/upload — stream a browser-selected file to a temp path
  on THIS server (the bot runs server-side, not on the operator's
  machine), so the path can feed a send action's file/image/record/video
  param. Raw bytes in the body (no multipart buffering), ?filename= for a
  readable suffix, streamed to disk under os.tmpdir()/snowluma-debug-
  upload with a 4 GiB cap and a 24h TTL sweep.

instance.invokeStream() delegates to ApiHandler.processStreamRequest.

Pure logic (filename sanitisation, fenced size-capped disk sink, the
stream-invoke Response builder) lives in debug-tools.ts with 12 unit
tests. Adversarially reviewed: path traversal is robust (19 hostile
filenames all fenced), frames provably never dropped, auth runs before
any disk write. Fixed a High found in review — the write stream had no
'error' listener (an async ENOSPC/EIO during flush would crash the whole
process) and a late-open race could leak a partial file; both now raced
into the awaits and cleaned up after 'close'.
2026-06-30 16:19:59 +08:00