Commit Graph
316 Commits
Author SHA1 Message Date
DrSmoothl 0e4d958b5f feat(highway): add stageSourceToDisk + shared HTTP downloader (phase 3)
Stage any upload source onto a local disk path so the uploader can stream it
without buffering the whole file. `stageSourceToDisk(source, maxBytes)` unifies:
local file:// / raw path → hardlink into an owned stage dir (O(1); the inode
link pins the bytes against a caller-side unlink/retention), streamed-copy
fallback on any link() failure; http(s):// → stream to a .part file; base64://
→ maxBytes-checked decode to a .part file. Temps are finalized atomically
(fsync file → rename → fsync dir); a TTL reaper reaps crash-orphaned temps while
an in-use `active` set protects live uploads (a hardlink keeps the source's old
mtime, so the staged path is reserved in `active` before it appears on disk).

To avoid divergence, the existing `loadBinarySource` HTTP transport is extracted
into a shared `downloadHttp(source, name, maxBytes, makeSink)` engine (browser
UA, redirect, timeout, Referer retry, declared + streamed size caps, noRetry);
`loadBinarySource` now drives it with an in-memory sink — behavior unchanged.

Additive only — not yet wired into video/file uploads. Tests cover the
retention race (hardlink survives the original's deletion), the reaper's
active-set protection, size caps, symlink resolution, the copy fallback, the
Referer retry reusing the .part, and streamed-overflow .part cleanup.
2026-07-05 03:01:10 +08:00
DrSmoothl 7b91903f80 feat(highway): add streaming file hasher hashFileStreaming (phase 2)
Single-pass, disk-streaming replacement for the buffered
`computeHashes` + `computeVideoSha1Blocks` (+ offline-file head md5): reads the
file 1 MiB at a time so hashing a multi-GiB upload never buffers the whole file.
Reproduces the buffered helpers exactly — whole-file md5/sha1, the per-1 MiB
intermediate SHA1 state snapshots plus the finalized whole-file SHA1, and an
optional md5 over the first N bytes (`headLimit`, for `md510MCheckSum`).

Additive only — not wired into any caller yet; the old helpers stay in place.
Lands in a new leaf module to avoid a utils↔highway-client import cycle. A
byte-oracle test asserts identity vs the buffered functions across sizes around
the 1 MiB block boundary (empty, sub-block, exact multiples, ±1, multi-block +
tail) and head limits across chunk boundaries.
2026-07-05 02:37:56 +08:00
DrSmoothl 5324e2f014 refactor(highway): introduce ChunkSource for uploadHighwayHttp (phase 1)
Prep for streaming large media uploads from disk instead of buffering the
whole file in RAM. Add a `ChunkSource` abstraction — `BufferChunkSource`
(in-memory, byte-for-byte the old path) and `FileChunkSource` (disk-backed,
reads exact-length chunks via a FileHandle, loops on short reads, throws on
unexpected EOF) — and change `uploadHighwayHttp` to consume a `ChunkSource`
instead of a `Uint8Array`. The uploader now owns the source and closes it once
in `finally` (best-effort; never masks a primary upload error).

Pure structural refactor: no streaming is wired yet. All five call sites
(pipeline runPuts, group + private file upload, personal + group avatar) wrap
their bytes in `BufferChunkSource`, so behavior is unchanged. Adds a
byte-oracle test proving FileChunkSource ≡ BufferChunkSource across block
boundaries, plus short-read/EOF/empty-input coverage.
2026-07-05 02:29:13 +08:00
DrSmoothl c236bc86ee feat(video): raise video-path size limit to 1.5 GiB (#145)
The 100 MB cap that re-routed larger videos to the file-upload pipeline
was a workaround for the width/height=0 → 已过期 bug (fixed in the prior
commit), not a real QQ size limit. Real-machine verified on tempserver:
88 MB / 149 MB / 299 MB / 497 MB videos all upload, render and play as
normal videos in a group. Raise MAX_VIDEO_SIZE to 1.5 GiB (matching the
file-upload ceiling); only videos above that still fall back to the file
path. Collapse the redundant MAX_VIDEO_SIZE_HARD (was also 1.5 GiB) into
the single constant and drop the now-dead 100 MB warn branches.

Note: the whole video is buffered in RAM for the Highway upload, so a
1.5 GiB send costs ~1.5 GiB+ of process memory — that, not a QQ limit,
is what bounds the ceiling.
2026-07-04 23:45:36 +08:00
DrSmoothl 9a6c17cb27 fix(video): carry real width/height on group video sends (#145)
A group video uploaded with fileInfo width/height = 0x0 reaches receivers
as 文件已过期 even though the resource is fresh and downloadable: QQ-NT
clients (Android especially) can't lay out the video tile without real
dimensions, so they render the expired placeholder. iOS is lenient — it
shows the expired badge but still opens the video, which is exactly the
"iOS 显示过期可以点开、安卓过期点不开" split users reported.

Real-machine capture on tempserver confirmed a working QQ group video's
MsgInfo carries real dimensions (e.g. 296x640) while ours sent 0x0. Fill
width/height from the probed video for group sends; c2c stays at 0 because
the QQ-NT server rejects non-zero dimensions there with a schema mismatch
(no c2c regression coverage yet). Verified end-to-end: a fresh 11 MB group
video that rendered 已过期 now displays and plays.
2026-07-04 23:15:03 +08:00
DrSmoothl 770b224d58 feat(onebot): decode incoming 闪传 flash-file messages (#199/#200)
Older QQ clients (≤9.9.30) deliver a 闪传 (flash transfer) file as a richui
markdown commonElem (svc=45, busId=FlashTransfer); SnowLuma had no decoder for
it, so the message decoded to 0 elements and was reported empty. (Newer
clients ≥9.9.31 send a plain text+link message that already decodes fine.)

Decode svc=45 markdown → parse the FlashTransfer card JSON → emit a flash_file
element, surfaced as the segment the issue asked for:
  { type: "flash_file", data: { title, file_set_id, scene_type } }

- Field names (fileSetId / sceneType / title) confirmed against QQ NT's
  flash-transfer manager in wrapper.node.i64. The card is built by the
  sender's client so the exact JSON nesting is unknown — values are pulled by
  a bounded recursive search rather than a pinned path (handles either shape).
- Non-FlashTransfer markdown is ignored; missing fields default (never empty).
- Manifest: D/S = yes, P/W = by-design-no (receive-only; sending is the
  existing send_flash_msg action). Log preview shows [闪传文件:title].
- Tests: card decode (nested + alias + defaults + non-flash) and the
  element→segment mapping.

Note: not end-to-end verified on a live message — the svc=45 format only comes
from ≤9.9.30 senders and no such client was available (every test sender here
is ≥9.9.31 → text format). Logic + field names are unit-tested / IDA-confirmed.

typecheck 12 包; protocol 596 / onebot 529 green.
2026-07-04 19:02:47 +08:00
H3CoF6andClaude Opus 4.8 0d03b00643 Fix(Qzone) -10000风控修复 && Fix(port-probe) PID探测多pid逻辑修复和偶数端口 (#192)
* fix(protocol): retry qzone msglist via user.qzone route on -10000

emotion_cgi_msglist_v6 through the legacy h5.qzone.qq.com →
taotao.qzone.qq.com gateway can be rejected with `-10000 使用人数过多`
for some accounts (issue #182). Real-device captures show the same CGI
via user.qzone.qq.com → taotao.qq.com is not rate-limited, so retry once
through that route when the legacy route returns -10000.

Behaviour-preserving for accounts the legacy route already serves: the
retry only fires on a -10000 envelope; all other codes, transport
errors, and the missing-msglist case are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(port-probe): fix port probe logic (parse accounts more than 2) and add description for 4302-4310 port.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:33:02 +08:00
Airline233andCopilot Autofix powered by AI d3eb3bae14 feat(qzone,onebot): add set_qzone_ban action for qzone blacklist management (#193)
* feat(onebot,qzone): add set_qzone_ban action for qzone blacklist management

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-04 14:32:40 +08:00
DrSmoothl 6cb7f2c6d0 chore(release): v1.11.2 2026-07-04 13:39:00 +08:00
DrSmoothl 07c08ed74d feat(onebot): enrich group/member info fields (#197)
Round out the OneBot v11 group-profile fields the issue asked for.

Group (get_group_info / get_group_list, inherited by get_group_info_ex /
get_group_detail_info):
- group_create_time + group_memo come free from the 0xFE5_2 list
  (createdTime / announcement), verified live (createdTime=1729505343,
  announcement populated).
- group_level lives only in the 0x88D_0 detail; get_group_info fetches it
  (memoized 5min) so a joined group doesn't hit the detail every call.
  get_group_list leaves it 0 — a per-group detail fetch across the whole
  list would be far too costly.

Member (get_group_member_info / get_group_member_list):
- Add area / unfriendly / title_expire_time / card_changeable. QQ NT does
  not expose these, so they're fixed placeholders ('' / false / 0 / true),
  matching go-cqhttp / NapCat. sex/age stay unknown/0 (real values need a
  per-member profile fetch — out of scope here).

typecheck 12 packages; onebot 523 / core 472 green.
2026-07-04 13:04:32 +08:00
DrSmoothl c027161a64 fix(protocol): get_group_file_system_info returns real counts + space (#196)
file_count 恒为 0(且 used_space/limit_count/total_space 是占位/写死值)。根因:
0x6D8 view 命令分三个 subcommand(1=List, 2=Count, 3=Space),但 getCount 错发到
**subCommand 3(Space)** 且 busId=0,并从 pb<1>/pb<2> 读计数——服务端根本没返回
Count 块,于是恒 0。

真机 RE(bot 在测试群传/删文件核对):
- Count 走 **0x6D8_2 + busId=6**,响应 Count 块里 fileCount=**field 4**、maxCount(文件
  数上限,如 1500)=**field 6**(旧 pb<1>/pb<2> 全落空)。
- Space 走 **0x6D8_3**,响应 Space 块 totalSpace=**field 4**、usedSpace=**field 5**(64 位)。
- 实测:fileCount=4、limitCount=1500、usedSpace=20103729、totalSpace=10737418240。

修复:改 GetGroupFileCount 到 subCommand 2 + busId 6 + 正确 tag;新增 GetGroupFileSpace
(0x6D8_3);action 并发查两者,四个字段全返回**服务端实际值**(优于 NapCat 的写死占位)。
含 count/space 编解码回归测试。catalog regen。

typecheck 12 包;protocol 591 / onebot 521 / core 472 全绿。
2026-07-04 01:59:52 +08:00
DrSmoothl 95b5878456 feat(onebot): return new folder info from create_group_file_folder (#195)
create_group_file_folder 之前只回 data:null,拿不到新建文件夹的 folderId。现对齐
NapCat 返回结构,data 带回文件夹信息:
  { result: { retCode, retMsg }, groupItem: { folderInfo: { folderId, folderName,
    folderPath, createTime, modifyTime, createUin, modifyUin } } }

- 0x6D7_0 创建响应本就在 field 4 携带 folderInfo,SnowLuma 旧 proto 没解(只读
  retcode/retMsg/clientWording)。补上 OidbGroupFileFolderInfoResp 并让 create 的
  deserialize / bridge / action 一路把它带出来。
- 真机验证(bot 在测试群建夹→读回 folderId→删除清理):确认 folderInfo 存在,且
  **modifyUin 在 field 9 而非 Lagrange proto 标注的 field 7**——按真机 wire 修正。
- 新增两个解码回归测试(带 folderInfo / 缺 folderInfo 兜底)。catalog regen。

typecheck 12 包;protocol 587 / onebot 521 / core 472 全绿。
2026-07-04 01:25:14 +08:00
DrSmoothl 7744e24af2 fix(protocol): make set_group_search actually apply search flags (#191)
旧实现走 OIDB 0x89a_0 但 settings 发空字节 → 服务端返 ok 却 no-op;action 也只收
group_id,没有设置搜索方式的参数(报告属实)。

真机 RE 定稿(tempserver,bot 为管理员的实群逐字段扫描 + GET 读回):
- 修改命令 = 0x89a_0(全量抓包确认,与 MuteAll 同命令,靠 body 形状区分)。结构
  = {1:groupUin, 2: 只含被改字段的 GroupDetailInfo modify 子消息}。这是与 0x88D_0
  群详情 GET **不同的消息域**(mute/shutUpTime 在此=tag17,在 GET 里却是字符串),
  所以标签不能照搬 GET。
- 在 modify 子消息里逐 tag 试写、经 0x88D_0 GET 读回效果,定位:
  noFingerOpen(关键词/群指纹搜索)=tag 35,noCodeFingerOpen(群号搜索/总开关)=tag 36;
  再对照客户端「被搜索方式」三选下拉(私密/群号/群号+关键词)核实语义与 0/1 取值。
- proto-defs:settings 改为 Oidb0x89a_0SearchSettings 子消息,两 flag 用 pb_optional
  (显式存在)——否则 proto3 丢 0 值,导致关闭(=0)被静默忽略。undefined 仍省略。
- set-search/bridge/action:透传 no_finger_open / no_code_finger_open;只下发提供的
  字段(同 NapCat filter 机制、与 modify-group-ext-info 一致)。catalog regen。
- 编码测试锁定:0 值必发 tag 35/36、未提供则省略、命令为 0x89a_0。

typecheck 12 包干净;protocol 585 / onebot 521 / core 472 全绿。
2026-07-03 17:37:55 +08:00
Airline233 884db940df feat(qzone): add set_qzone_msg_right to change a feed's visibility (#186)
* feat(qzone): add set_qzone_msg_right to change a feed's visibility

Add updateQzoneMsgRight in the protocol layer, porting php-qzone's
updateRight: fetch the feed via emotion_cgi_msgdetail_v6, rebuild the
full publish-shaped payload (richval/pic_bo/con), then override
ugc_right/allow_uins and POST to emotion_cgi_update. Expose it through
QzoneApi.updateRight and the OneBot set_qzone_msg_right action, with the
same ugc_right/target_uins validation as send_qzone_msg. Covered by
protocol, core and onebot tests.

* fix(qzone): make qzoneParse more stable and fixed a test example
2026-07-02 22:38:05 +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 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 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 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 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 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 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
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 48e2777bc3 chore(release): v1.11.0 2026-06-30 20:38:37 +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 c839121fdb fix(protocol): send super/new faces with the right wire encoding (#168)
A face was always built as a legacy FaceElem (face.index). QQ-NT only renders
classic small faces that way; newer 'super'/animated faces sent as face.index
are silently remapped by the server (e.g. 424→168) or shown as a placeholder.

Classify each face id off QQ's own system-face catalog (0x9154_1, ported from
SLE/Lagrange) and pick the matching wire form:
  - super (animated, aniSticker pack ≠ (1,1)) → CommonElem 37 + QFaceExtra
  - other id ≥ 260                            → CommonElem 33 + QSmallFaceExtra
  - classic id < 260                          → legacy FaceElem

The catalog is fetched once (shared, lazy-warmed off the first face send) and
classify falls back to an id-range guess until it lands. Data-driven so all 66
non-(1,1) super faces encode correctly, not just a hardcoded few.

Verified on a live account: 424→424 (was 168), super 392→392, classic 14→14.
2026-06-30 16:09:28 +08:00
Airline233 62eb8fd763 feat: Qzone支持上传图片(发说说、评论附图) (#152)
* feat(core,onebot,protocol): support publish shuoshuo and comment with image

* Fix: Accidentally changed notes
2026-06-30 01:47:21 +08:00
DrSmoothl 053137669a fix(protocol): drop the ineffective video force-full-on-fast-path retry (#145)
forceFullOnFastPath re-issued the NTV2 upload with tryFastUploadCompleted:
false to try to refresh a possibly-expired video resource. Real-machine
testing on a live account + RE of the QQ NT kernel (wrapper.node) proved it
never works:
- the server's fast-path is driven by md5-metadata existence and ignores
  the flag (proton omits a false bool anyway); the retry re-fast-paths
- the kernel itself does no validity check — HandleRspUploadV3 trusts the
  response's fileExist and goes straight to SendUploadSuccFTN

So an expired-but-still-indexed video resource is a platform-level
limitation, not something the upload flow can refresh. Removing the retry
drops two wasted OIDB round-trips per cached-video re-send. The underlying
#145 (receiver '资源已过期' for a server-expired resource) remains a
platform issue, tracked separately.

Tests updated to assert the single-request behaviour.
2026-06-29 15:32:57 +08:00
DrSmoothl 7ec74f2928 chore(release): v1.10.4 2026-06-28 22:48:00 +08:00
DrSmoothl 5d39cf5782 fix(core): finalize c2c file upload so the recipient can download (#157)
Private (c2c) file sends uploaded the bytes (apply 0xE37_1700 + Highway)
and PbSendMsg returned ok, but the receiver saw "文件传输失败": the message
carried only FileExtra.file (metadata) and not the server-issued download
routing. Group files use a different element, hence c2c-only.

Port NapCat's upload→finalize→send sequence:
- add 0xE37_800 (FinalizeOfflineFile) OIDB service, run after the Highway
  PUT to fetch the download metadata (field30)
- extend FileExtra with field6 (PrivateFileExtra.field2) and populate it
  from that metadata in sendC2cFile; covers both the url-upload and the
  file_id-resend send paths (both funnel through sendC2cFile)

Tests: byte-oracle locks the 0xE37_800 request + FileExtra.field6 tags;
core routing test now asserts finalize runs and field6 is populated.
Needs real-machine verification (send a private file, confirm download).
2026-06-28 20:11:17 +08:00
DrSmoothl e1f5267764 fix(onebot): make group large-video file fallback actually fire
#153 added a video→file fallback but the group side was dead code: it
lived in element-builder behind `isGroup && element.fileId`, and a video
sent by url/path has no fileId, so oversize group videos just rethrew the
raw Highway error (real bot: `error_code=323` on a 1.4 GB group send).

Move the fallback to the OneBot layer for groups, mirroring the private
path: on a failed Highway send, re-route oversize videos through
groupFile.upload. Verified on a live bot — a 1.4 GB group video now lands
as `[文件:video.mp4]` instead of failing.

Extract the shared heuristic (isHighwaySizeError / videoNeedsFileFallback
/ splitVideoFileFallback) so private and group stay in sync, and drop the
now-unreachable element-builder video catch. Adds send-video-fallback
tests covering both the known-oversize and remote-error-heuristic paths.
2026-06-27 16:27:33 +08:00
stla 85182795b5 feat(protocol,onebot): raise video size limit with Highway-first fallback (#153)
Round 3: fix hardcoded 4096 MB in error messages
- Use MAX_VIDEO_SIZE_HARD constant instead of hardcoded 4096 MB
- Both throw sites now derive limit from the same constant
2026-06-27 16:10:25 +08:00
DrSmoothl 351db573d8 chore(release): v1.10.3 2026-06-26 19:49:41 +08:00
DrSmoothl 3b023b3275 fix(protocol): collapse ark/card messages structurally, not by string match (#146)
Follow-up to the previous #146 fix, which dropped QQ's compat fallback text by
content-matching the "版本不支持 … 升级" family. RE of the QQ kernel
(wrapper.linux.node) showed that is not how QQ does it:
- the receiver binary contains NONE of these fallback strings, so QQ cannot be
  matching by content;
- the on-target wire is [lightApp, text(compat), generalFlags, extraInfo] and the
  text element has no distinguishing marker (no pbReserve / attr6Buf);
- QQ NT's kernel codec (msg_codec_mgr) collapses the message to a single ark
  element — the sibling text is never emitted. NapCat maps kernel elements 1:1
  with no ark-aware skip yet still surfaces only the card, confirming the kernel
  drops it upstream.

Switch to that structural rule: when a lightApp/richMsg card is present, drop
sibling plain text outright. It is version/locale-proof (won't break when Tencent
rewords the string) and matches QQ NT + NapCat output exactly. @ mentions /
replies / faces are not plain text and survive; QQ clients can't attach a caption
to a card anyway, so no QQ-origin message loses real text.

Verified live on a logged-in build: get_msg returns a single json segment.
2026-06-26 18:31:43 +08:00
DrSmoothl 319c7d607d fix(protocol): drop QQ ark-compat fallback text beside mini-app cards (#146)
A QQ mini-program / ark share (B站 video, QQ 小程序, …) arrives on the wire
as a lightApp/richMsg ark element followed by a plain text element carrying
QQ's graceful-degradation compat string "当前QQ版本不支持此应用,请升级". That
text is meant only for protocol-old clients that can't render the card; QQ NT
and the kernel-backed bridges (NapCat) surface just the card.

On-target capture (test group) confirmed the text element has no distinguishing
wire marker (no pbReserve / attr6Buf) — it is a bare text sibling of the ark.
SnowLuma was decoding both, so every mini-app card came through as the card
plus a spurious "当前QQ版本不支持" text segment.

Drop the compat string when it sits beside a card, matched narrowly (the
"版本不支持 … 升级/更新" family, <=40 chars) so genuine user captions sent
alongside a card are preserved.

Verified live on a logged-in build: get_msg now returns a single json segment.
2026-06-26 18:08:26 +08:00
Airline233 39338c3f1d fix(protocol,onebot): generate and send ark of contact cards correctly (#149) 2026-06-26 17:04:40 +08:00
DrSmoothl 3eba2a677c fix(highway): force full video upload when server fast-paths a stale resource (#145)
Group/c2c video resources expire server-side. When the bot re-sends a
video whose md5 the server already cached, the NTV2 OIDB returns no uKey
for the main file (fast-upload hit) and SnowLuma trusted it — the send
"succeeds" but the receiver sees 资源已过期, because the reused object had
expired. Confirmed by the reporter: same MP4 fast-paths and breaks, but
mutating one byte (new md5) forces a full upload and plays fine.

Add a per-sub `forceFullOnFastPath` flag on MediaSubFileUpload. When a
flagged sub-file (video main) carries real bytes but the server fast-paths
it, runNtv2Upload re-issues the OIDB request with tryFastUploadCompleted
disabled (proton drops a `false` bool, which the server reads as "no
fast-upload"), forcing a fresh resource + full PUT. The thumb stays
fast-path-OK so a cached thumb never re-pushes the main video; forwarding
paths carry no bytes so they never trigger the retry.

Also fixes the observability gap the reporter hit: the main file being
silently skipped on a fast-path now logs explicitly, and so does the
forced-retry, instead of only the thumb PUT showing up.

Note: the retry relies on the server treating an absent
tryFastUploadCompleted as "no fast-upload" — needs real-device
confirmation that the forced round actually returns a uKey.
2026-06-25 20:52:11 +08:00
DrSmoothl 1bdb8a40c4 fix(protocol): dedup duplicate system pushes (#137 official-robot double group_increase)
Inviting an official robot (e.g. 2854207029) into a group made QQ push the
group_member_increase notice twice, so SnowLuma emitted two
notice.group_increase events; a normal member produced one.

Root cause (RE of wrapper.linux.node): QQ NT dedups every received system
message in sys_msg_mgr.cc::ProcessRecvSysMsg (sub_37BADE0) by a global_key
`{peerUid}_{chatType}_{msg_seq}_{random}` (sub_37BC4E0) — an already-seen key
is dropped ("ignore dup msg") before any listener sees it. The per-message
discriminators msg_seq (attr 40003 = contentHead field 5) and random (attr
40002 = contentHead field 4) are exactly head.sequence and head.msgId
(msg_header_codec_helper.cc sub_37C8AD0). Kernel-based bots (NapCat/LLOneBot)
receive events after this dedup; SnowLuma reads the raw OlPush before it.

Mirror it: SysMsgDedup, a per-account bounded ring that drops a duplicate
system push by (msgType, subType, fromUin, sequence, msgId). Push-level (drops
the whole duplicate like NT), live path only (Bridge owns the tracker; the
forward re-parse passes none), system kinds only (chat messages excluded), and
never dedups a push lacking a server identity (sequence or msgId 0).
2026-06-23 20:36:24 +08:00
DrSmoothl 4e0ec92c23 fix(core): emit group_decrease + drop cache on set_group_leave (#133)
The server never pushes a member-decrease back to the member that left of its
own accord, so set_group_leave produced no notice.group_decrease — downstream
kept a 'zombie' group in its cache, and a later set_group_add_request failed
with 'matching group request not found'.

bridge.leave() now, after the 0x1097_1 OIDB succeeds, synthesizes a self
group_member_leave (user_id = operator_id = self, sub_type 'leave') through the
event bus — converted to notice.group_decrease by the existing path — and
calls identity.forgetGroup() so get_group_list / member lookups drop it
immediately (in-memory + DB inactive).

Kick / disband already arrive via the GroupMemberDecrease push (PkgType 34).
Limitation: a MANUAL leave in the NTQQ client isn't covered — SL is packet-
based with no kernel callback, and a voluntary self-leave isn't pushed back;
only kernel-based clients (NapCat/LLOneBot) catch that.

Validated by unit tests (bridge leave emits the event + forgets the group).
2026-06-22 23:07:44 +08:00
DrSmoothl b918450483 fix(protocol): set_group_special_title — send uinName so the title actually applies
The 0x8FC_2 body omitted uinName (tag 7), so the server accepted the request
(errorCode 0) but silently never applied the title — looking like the feature
was unsupported. Both NapCat (Oidb.0x8FC_2: uinName=title) and Lagrange
(0x8FC body @7 UinName=title) set it; mirror them by sending uinName = title.

No RE needed — two independent packet-based implementations agree on the field.

Note: setting a special title is group-owner-only, so on-target validation
requires a group the bot owns.
2026-06-22 21:53:33 +08:00
DrSmoothl 1bd126562f chore(release): v1.10.2 2026-06-22 21:31:06 +08:00
DrSmoothl e160837f8b feat(onebot): tiered reply backfill + quoted-file recovery for get_msg (#128)
Harvests the sound ideas from PR #128 while KEEPING origSeqs[0] as the c2c
reply sequence (on-target capture in #114/#124 proved origSeqs[0] == the quoted
message's head.sequence; pbReserve.friendSequence is a small unrelated counter
that makes reply.id miss). The PR's friendSequence override is intentionally
NOT taken.

- rich-body-decoder: enrich the reply element with replySenderUin/replyTime and
  the quoted message's own decoded elements (SrcMsg.elems, field 5); recover a
  c2c quoted FILE from sourceMsg (field 9 -> RichText.notOnlineFile) when elems
  carried none, so a quoted file survives into get_msg (#124).
- backfillReplyTarget: tiered fallback — Tier 0 local store, Tier 1 server fetch
  (correct seq = origSeqs[0]), Tier 2 reconstruct from the embedded quoted
  elements (no round-trip), Tier 3 minimal '[引用消息]' placeholder so get_msg
  never returns 'message not found'.
- to-segment: per-element try/catch (logged, not silent) so one bad element
  doesn't drop the whole message.
- actions/message: log get_msg misses.
- tests: fix request-actions getGroupInviteCardSequence mock (gap from #125);
  cover backfill Tier 2 (reconstruct) + Tier 3 (placeholder); flip the stale
  c2c reply test to assert origSeqs[0] (was asserting the disproven
  friendSequence).

Validated on-target (Docker): get_msg on group reply, private text reply, and a
private quoted FILE all return correct content (file with name/size/id); full
onebot + protocol suites green.
2026-06-22 21:25:05 +08:00
DrSmoothl 4593ba3340 fix(protocol): strip reply auto-mention on receive; align send to uin=0 (#127)
A QQ NT reply carries the replied sender as a structural auto-mention
(MentionExtra.type=2, uin=0) right after srcMsg, plus a blank separator
text. SnowLuma reported both as a real [CQ:at] + empty/space segment, so
downstreams saw a phantom @ (#127). A real user @ carries a non-zero
MentionExtra.uin, so it is preserved.

- receive (rich-body-decoder): after a reply, drop a type=2/uin=0 mention
  and the blank text that follows it; keep non-zero-uin mentions.
- send (element-builder, #129 follow-up): the reply auto-mention added for
  reply+@ now uses uin=0 (real uid stays in `uid`) to match QQ's native wire
  shape, so the bot's own replies are stripped the same way by receivers and
  don't surface a phantom @ either.

Validated on-target (Docker, group): reply w/o @ -> [reply, text] (no
phantom @/empty); reply w/ @ -> [reply, at(real), text]; bot reply+@ still
delivers the @ (uin=0).
2026-06-22 20:53:10 +08:00
DrSmoothl 45aae92af5 fix(protocol): group reply+at — emit replied-sender mention after srcMsg (#129)
When a group message combined a reply with a user @ , the @ showed but
never notified (no blue link, no ping). On-target capture showed SnowLuma
sent [srcMsg, @target(mention), text] — the @ lands directly in the reply's
mention slot right after srcMsg, which the QQ NT group server does not honor.

Mirror Lagrange's ForwardEntity.PackElement: when a reply and an @ coexist
in a group message, emit the replied sender as a trailing mention element
right after srcMsg, so the user's @ is no longer the first post-srcMsg
element and is honored. Gated to the reply+@ case so plain replies are
unchanged.

Validated on-target (Docker, group send): baseline reply+at -> @ not
delivered; fixed -> @ delivered (blue link + notification).
2026-06-22 20:37:19 +08:00
DrSmoothl 839de51519 fix(protocol): c2c reply uses origSeqs[0], not friendSequence (#114, #124)
A private (c2c) reply resolved to srcMsg.reserve.friendSequence, but a
received message's message_id is hashed from its head.sequence. On-target
capture showed origSeqs[0] == head.sequence (e.g. 12709) while friendSequence
is a small friend-relationship counter (e.g. 25) — so reply.id != the quoted
message_id. Downstream get_msg(reply_id) then missed:

- #114: reported reply id didn't match the quoted message's real id.
- #124: replying to a File message returned empty quoted content (AstrBot
  relies on get_msg(reply_id) to learn the quote was a file; text replies
  appeared fine because they read the inline follow-up text instead).

Drop the c2c friendSequence override (reverting cf8e62d's premise, which was
backwards) so both group and c2c replies use origSeqs[0].

Validated on-target (Docker): file message id == reply.id, and
get_msg(reply_id) -> retcode 0, message [{type:file}].
2026-06-22 16:07:36 +08:00
DrSmoothl 11e7584d1c fix(onebot): approve bot self-invite via private card msgseq (#125)
When the bot is invited to a group via a private-chat "qun.invite" ark
card, set_group_add_request failed: handleGroupAddRequest re-derived the
approval tuple from fetchGroupRequests (0x10c8_2), but for a self-invite the
only sequence the server accepts at 0x10c8_1 is the msgseq embedded in the
card's mqqapi://group/invite_join jumpUrl, applied with eventType=2 /
filtered=false. The MSF invite push (PkgType 87) never carries that msgseq,
so approval returned 120161001 'handle async message fail'.

- friend-message decoder: parse the private invite card's jumpUrl and stash
  groupUin -> msgseq on the per-instance IdentityService.
- ContactsApi.getGroupInviteCardSequence(groupId) exposes it.
- handleGroupAddRequest: for an invite flag, if a card msgseq is cached,
  approve with setAddRequest(groupId, msgseq, 2, approve, reason, false);
  otherwise fall back to the existing fetchGroupRequests path.

Validated on-target (Docker): private invite card -> set_group_add_request
approve -> 0x10c8_1 ok -> bot joined the group (get_group_member_info shows
role=member, fresh join_time); no 120161001.
2026-06-22 15:25:28 +08:00
DrSmoothl 8b87f80951 chore(release): v1.10.1 2026-06-22 01:45:25 +08:00
DrSmoothl b135752577 fix(highway): retry chunk upload on transient transport errors (#118)
Large private files (e.g. 48 MB .flac via send_private_msg) failed with
"connection closed before response (received 0 bytes, headerSeen=false)".
uploadHighwayHttp sends one fresh TCP connection per 1 MB chunk with no
retry, so a 48 MB file is ~48 sequential connections and a single transient
peer-FIN / reset among them aborted the entire upload. Small files (1 chunk)
always worked; NapCat is unaffected because it delegates to QQ's native
highway client, which retries.

Wrap each chunk's connect + POST in a bounded retry (3 attempts, 300 ms ×
attempt backoff, fresh connection each time). Re-sending the same offset
range is idempotent server-side. Only transport failures retry; a decoded
highway error_code is a definitive server reject and is not retried.
2026-06-22 01:45:12 +08:00
DrSmoothl 9efe16237d chore(release): v1.10.0 2026-06-21 20:22:46 +08:00